From cff7c7e1ebb3c1c91fd8c97e66ab204f94f14971 Mon Sep 17 00:00:00 2001 From: guqing <38999863+guqing@users.noreply.github.com> Date: Thu, 5 Dec 2024 15:59:14 +0800 Subject: [PATCH] feat: restructure RSS generation to support extension by other plugins (#39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What this PR does? 重写 RSS 生成并支持被其他插件扩展 ```release-note 重写 RSS 生成并支持被其他插件扩展 ``` --- .github/workflows/cd.yaml | 1 + .github/workflows/ci.yaml | 1 + .gitignore | 1 + README.md | 104 ++++++++- api/build.gradle | 76 ++++++ .../java/run/halo/feed/CacheClearRule.java | 40 ++++ api/src/main/java/run/halo/feed/RSS2.java | 186 +++++++++++++++ .../run/halo/feed/RssCacheClearRequested.java | 68 ++++++ .../main/java/run/halo/feed/RssRouteItem.java | 40 ++++ app/build.gradle | 34 +++ .../main/java/run/halo/feed/BasicProp.java | 38 +++ .../main/java/run/halo/feed/FeedPlugin.java | 0 .../run/halo/feed/FeedPluginEndpoint.java | 112 +++++++++ .../java/run/halo/feed/RssCacheManager.java | 93 ++++++++ .../java/run/halo/feed/RssXmlBuilder.java | 212 +++++++++++++++++ .../main/java/run/halo/feed/XmlCharUtils.java | 0 .../provider/AbstractPostRssProvider.java | 151 ++++++++++++ .../feed/provider/AuthorPostRssProvider.java | 80 +++++++ .../provider/CategoryPostRssProvider.java | 93 ++++++++ .../halo/feed/provider/PostRssProvider.java | 22 ++ .../run/halo/feed/service/PostService.java | 16 ++ .../halo/feed/service/PostServiceImpl.java | 84 +++++++ .../resources/extensions/ext-definition.yaml | 30 +++ .../main/resources/extensions/settings.yaml | 6 + {src => app/src}/main/resources/logo.svg | 0 {src => app/src}/main/resources/plugin.yaml | 3 +- app/src/test/java/run/halo/feed/RSS2Test.java | 132 +++++++++++ build.gradle | 34 +-- settings.gradle | 7 +- src/main/java/run/halo/feed/BasicSetting.java | 32 --- .../java/run/halo/feed/ContentWrapper.java | 43 ---- .../run/halo/feed/FeedPluginEndpoint.java | 40 ---- src/main/java/run/halo/feed/FeedService.java | 32 --- .../java/run/halo/feed/FeedServiceImpl.java | 218 ------------------ .../java/run/halo/feed/FeedSourceFinder.java | 65 ------ .../run/halo/feed/FeedSourceFinderImpl.java | 116 ---------- src/main/java/run/halo/feed/PatchUtils.java | 87 ------- src/main/java/run/halo/feed/RSS2.java | 67 ------ .../run/halo/feed/ReactiveSettingFetcher.java | 37 --- src/test/java/run/halo/feed/RSS2Test.java | 54 ----- 40 files changed, 1621 insertions(+), 834 deletions(-) create mode 100644 api/build.gradle create mode 100644 api/src/main/java/run/halo/feed/CacheClearRule.java create mode 100644 api/src/main/java/run/halo/feed/RSS2.java create mode 100644 api/src/main/java/run/halo/feed/RssCacheClearRequested.java create mode 100644 api/src/main/java/run/halo/feed/RssRouteItem.java create mode 100644 app/build.gradle create mode 100644 app/src/main/java/run/halo/feed/BasicProp.java rename {src => app/src}/main/java/run/halo/feed/FeedPlugin.java (100%) create mode 100644 app/src/main/java/run/halo/feed/FeedPluginEndpoint.java create mode 100644 app/src/main/java/run/halo/feed/RssCacheManager.java create mode 100644 app/src/main/java/run/halo/feed/RssXmlBuilder.java rename {src => app/src}/main/java/run/halo/feed/XmlCharUtils.java (100%) create mode 100644 app/src/main/java/run/halo/feed/provider/AbstractPostRssProvider.java create mode 100644 app/src/main/java/run/halo/feed/provider/AuthorPostRssProvider.java create mode 100644 app/src/main/java/run/halo/feed/provider/CategoryPostRssProvider.java create mode 100644 app/src/main/java/run/halo/feed/provider/PostRssProvider.java create mode 100644 app/src/main/java/run/halo/feed/service/PostService.java create mode 100644 app/src/main/java/run/halo/feed/service/PostServiceImpl.java create mode 100644 app/src/main/resources/extensions/ext-definition.yaml rename {src => app/src}/main/resources/extensions/settings.yaml (84%) rename {src => app/src}/main/resources/logo.svg (100%) rename {src => app/src}/main/resources/plugin.yaml (94%) create mode 100644 app/src/test/java/run/halo/feed/RSS2Test.java delete mode 100644 src/main/java/run/halo/feed/BasicSetting.java delete mode 100644 src/main/java/run/halo/feed/ContentWrapper.java delete mode 100644 src/main/java/run/halo/feed/FeedPluginEndpoint.java delete mode 100644 src/main/java/run/halo/feed/FeedService.java delete mode 100644 src/main/java/run/halo/feed/FeedServiceImpl.java delete mode 100644 src/main/java/run/halo/feed/FeedSourceFinder.java delete mode 100644 src/main/java/run/halo/feed/FeedSourceFinderImpl.java delete mode 100644 src/main/java/run/halo/feed/PatchUtils.java delete mode 100644 src/main/java/run/halo/feed/RSS2.java delete mode 100644 src/main/java/run/halo/feed/ReactiveSettingFetcher.java delete mode 100644 src/test/java/run/halo/feed/RSS2Test.java diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml index 929d953..e83fa03 100644 --- a/.github/workflows/cd.yaml +++ b/.github/workflows/cd.yaml @@ -16,3 +16,4 @@ jobs: with: app-id: app-KhIVw skip-node-setup: true + artifacts-path: 'app/build/libs' diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6efa4de..fec1287 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -13,3 +13,4 @@ jobs: uses: halo-sigs/reusable-workflows/.github/workflows/plugin-ci.yaml@v1 with: skip-node-setup: true + artifacts-path: 'app/build/libs' diff --git a/.gitignore b/.gitignore index c97ac56..1e7a540 100755 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,4 @@ application-local.properties /admin-frontend/node_modules/ /workplace/ +*/workplace/ \ No newline at end of file diff --git a/README.md b/README.md index 95843a2..24bab3e 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,92 @@ Halo 2.0 的 RSS 订阅链接生成插件 +## 如何扩展 RSS 源 + +> 从 feed 插件 v1.4.0 版本开始,支持扩展 RSS 功能。 + +`feed` 插件提供了扩展点,允许其他插件扩展 RSS 源。 + +### 步骤 1:在插件中引入 feed 依赖 + +在你的插件项目中添加 `feed` 插件的依赖: + +```groovy +dependencies { + // ... + compileOnly "run.halo.feed:api:{version}" +} +``` + +将 `{version}` 替换为实际的 `feed` 插件版本号。 + +### 步骤 2:实现 `RssRouteItem` 扩展点接口 + +创建一个类实现 `run.halo.feed.RssRouteItem` 接口,提供自定义的 RSS 数据源。例如: + +```java +public class MomentRssProvider implements RssRouteItem { + // 实现具体的 RSS 提供逻辑 +} +``` + +你可以参考 [PostRssProvider](./app/src/main/java/run/halo/feed/provider/PostRssProvider.java) 示例。 + +### 步骤 3:声明扩展点 + +在 `src/main/resources/extensions` +目录下,声明你的扩展。你可以参考 [ext-definition.yaml](app/src/main/resources/extensions/ext-definition.yaml) 文件来完成此步骤。 + +### 步骤 4:定义配置类并清理 RSS 缓存 + +在插件中定义一个配置类,使用 `@ConditionalOnClass` 注解确保只有在 `run.halo.feed.RssRouteItem` 类存在时才会创建对应的 +Bean。同时,定义事件监听器来清理缓存。 + +`@ConditionalOnClass` 注解只能使用 name 属性来指定类全限定名,不支持使用 value 属性。 + +示例代码: + +```java + +@Configuration +@ConditionalOnClass(name = "run.halo.feed.RssRouteItem") +@RequiredArgsConstructor +public class RssAutoConfiguration { + private final ApplicationEventPublisher eventPublisher; + + @Bean + public MomentRssProvider momentRssProvider() { + return new MomentRssProvider(); + } + + @Async + @EventListener({MomentUpdatedEvent.class, MomentDeletedEvent.class, ContextClosedEvent.class}) + public void onMomentUpdatedOrDeleted() { + var rule = CacheClearRule.forExact("/feed/moments/rss.xml"); + var event = RssCacheClearRequested.forRule(this, rule); + eventPublisher.publishEvent(event); + } +} +``` + +此配置确保了当 `RssRouteItem` 接口存在时,插件才会自动创建 `MomentRssProvider` 并监听相关事件来清理缓存。 + +### 步骤 5:声明插件依赖 + +在 `plugin.yaml` 文件中声明 `feed` 插件为可选依赖,确保当 `feed` 插件存在并启用时,插件能够自动注册 RSS 源。 + +```yaml +apiVersion: plugin.halo.run/v1alpha1 +kind: Plugin +metadata: + name: moment +spec: + pluginDependencies: + "PluginFeed?": ">=1.4.0" +``` + +这样,当 `feed` 插件可用时,插件会自动注册自定义的 RSS 源。 + ## 开发环境 ```bash @@ -28,15 +114,15 @@ cd path/to/plugin-feed ```yaml halo: - plugin: - runtime-mode: development - classes-directories: - - "build/classes" - - "build/resources" - lib-directories: - - "libs" - fixedPluginPath: - - "/path/to/plugin-feed" + plugin: + runtime-mode: development + classes-directories: + - "build/classes" + - "build/resources" + lib-directories: + - "libs" + fixedPluginPath: + - "/path/to/plugin-feed" ``` ## 使用方式 diff --git a/api/build.gradle b/api/build.gradle new file mode 100644 index 0000000..4a8918d --- /dev/null +++ b/api/build.gradle @@ -0,0 +1,76 @@ +plugins { + id 'java-library' + id 'maven-publish' + id "io.freefair.lombok" version "8.0.0-rc2" +} + +group = 'run.halo.feed' +version = rootProject.version + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + withSourcesJar() +} + +compileJava.options.encoding = "UTF-8" +compileTestJava.options.encoding = "UTF-8" +javadoc.options.encoding = "UTF-8" + +dependencies { + api platform('run.halo.tools.platform:plugin:2.20.11') + compileOnly 'run.halo.app:api' +} + +test { + useJUnitPlatform() +} + +publishing { + publications { + mavenJava(MavenPublication) { + from components.java + artifact tasks.sourcesJar + + artifactId = 'api' + version = project.hasProperty('version') ? project.property('version') : 'unspecified' + + pom { + name = 'RSS' + description = '为站点生成 RSS 订阅链接' + url = 'https://www.halo.run/store/apps/app-KhIVw' + + licenses { + license { + name = 'GPL-3.0' + url = 'https://github.com/halo-dev/plugin-feed/blob/main/LICENSE' + } + } + + developers { + developer { + id = 'guqing' + name = 'guqing' + email = 'i@guqing.email' + } + } + + scm { + connection = 'scm:git:git@github.com:halo-dev/plugin-feed.git' + developerConnection = 'scm:git:git@github.com:halo-dev/plugin-feed.git' + url = 'https://github.com/halo-dev/plugin-feed' + } + } + } + } + repositories { + maven { + url = version.endsWith('-SNAPSHOT') ? 'https://s01.oss.sonatype.org/content/repositories/snapshots/' : + 'https://s01.oss.sonatype.org/content/repositories/releases/' + credentials { + username = project.findProperty("ossr.user") ?: System.getenv("OSSR_USERNAME") + password = project.findProperty("ossr.password") ?: System.getenv("OSSR_PASSWORD") + } + } + } +} diff --git a/api/src/main/java/run/halo/feed/CacheClearRule.java b/api/src/main/java/run/halo/feed/CacheClearRule.java new file mode 100644 index 0000000..14695ae --- /dev/null +++ b/api/src/main/java/run/halo/feed/CacheClearRule.java @@ -0,0 +1,40 @@ +package run.halo.feed; + +import org.springframework.util.Assert; + +public record CacheClearRule(Type type, String value) { + public CacheClearRule { + Assert.notNull(type, "Type cannot be null"); + Assert.notNull(value, "Value cannot be null"); + if (type == Type.EXACT && !value.startsWith("/")) { + throw new IllegalArgumentException("Exact value must start with /"); + } + } + + public static CacheClearRule forPrefix(String prefix) { + return new CacheClearRule(Type.PREFIX, prefix); + } + + public static CacheClearRule forExact(String exact) { + return new CacheClearRule(Type.EXACT, exact); + } + + public static CacheClearRule forContains(String contains) { + return new CacheClearRule(Type.CONTAINS, contains); + } + + @Override + public String toString() { + return "CacheClearRule{" + + "type=" + type + + ", value='" + value + '\'' + + '}'; + } + + public enum Type { + PREFIX, + EXACT, + CONTAINS + } +} + diff --git a/api/src/main/java/run/halo/feed/RSS2.java b/api/src/main/java/run/halo/feed/RSS2.java new file mode 100644 index 0000000..d335eea --- /dev/null +++ b/api/src/main/java/run/halo/feed/RSS2.java @@ -0,0 +1,186 @@ +package run.halo.feed; + +import jakarta.validation.constraints.NotBlank; +import java.time.Instant; +import java.util.List; +import lombok.Builder; +import lombok.Data; +import lombok.Singular; + +@Data +@Builder +public class RSS2 { + /** + * (Recommended) The name of the feed, which should be plain text only + */ + @NotBlank + private String title; + + /** + * (Recommended) The URL of the website associated with the feed, which should link to a + * human-readable website + */ + @NotBlank + private String link; + + /** + * (Optional) The summary of the feed, which should be plain text only + */ + private String description; + + /** + * The primary language of the feed, which should be a value from + * RSS Language Codes or ISO + * 639 language codes + */ + private String language; + + /** + * (Recommended) The URL of the image that represents the channel, which should be relatively + * large and square + */ + private String image; + + @Singular + private List items; + + @Data + @Builder + public static class Item { + /** + * (Required) The title of the item, which should be plain text only + */ + @NotBlank + private String title; + + /** + * (Recommended) The URL of the item, which should link to a human-readable website + */ + @NotBlank + private String link; + + /** + *

(Recommended) The content of the item. For an Atom feed, it's the atom:content + * element.

+ *

For a JSON feed, it's the content_html field.

+ */ + @NotBlank + private String description; + + /** + * (Optional) The author of the item + */ + private String author; + + /** + * (Optional) The category of the item. You can use a plain string or an array of strings + */ + @Singular + private List categories; + + /** + * (Recommended) The publication + * date of the item, which should be a Date object + * following the standard + */ + private Instant pubDate; + + /** + * (Optional) The unique identifier of the item + */ + private String guid; + + /** + * (Optional) The URL of an enclosure associated with the item + */ + private String enclosureUrl; + + /** + * (Optional) The size of the enclosure file in byte, which should be a number + */ + private String enclosureLength; + + /** + * (Optional) The MIME type of the enclosure file, which should be a string + */ + private String enclosureType; + + /** + * (Optional) Media content, represented by the element. + */ + @Singular + private List mediaContents; + } + + @Data + @Builder + public static class MediaContent { + /** + * URL of the media object. + */ + private String url; + + /** + * Type of the media, such as "image/jpeg", "audio/mpeg", "video/mp4". + */ + private String type; + + /** + * The general type of media: image, audio, video. + */ + private MediaType mediaType; + + /** + * File size of the media object in bytes. + */ + private String fileSize; + + /** + * Duration of the media object in seconds (for audio and video). + */ + private String duration; + + /** + * Height of the media object in pixels (for image and video). + */ + private String height; + + /** + * Width of the media object in pixels (for image and video). + */ + private String width; + + /** + * Bitrate of the media (for audio and video). + */ + private String bitrate; + + /** + * Thumbnail associated with this media content. + */ + private MediaThumbnail thumbnail; + + public enum MediaType { + IMAGE, AUDIO, VIDEO, DOCUMENT + } + } + + @Data + @Builder + public static class MediaThumbnail { + /** + * URL of the thumbnail. + */ + private String url; + + /** + * Height of the thumbnail in pixels. + */ + private String height; + + /** + * Width of the thumbnail in pixels. + */ + private String width; + } +} diff --git a/api/src/main/java/run/halo/feed/RssCacheClearRequested.java b/api/src/main/java/run/halo/feed/RssCacheClearRequested.java new file mode 100644 index 0000000..cae3b4b --- /dev/null +++ b/api/src/main/java/run/halo/feed/RssCacheClearRequested.java @@ -0,0 +1,68 @@ +package run.halo.feed; + +import java.util.ArrayList; +import java.util.List; +import lombok.Getter; +import org.springframework.context.ApplicationEvent; +import run.halo.app.plugin.SharedEvent; + +/** + * Represents an event to request the clearing of RSS cache with flexible rules. + * This event allows specifying multiple strategies for cache invalidation, including + * prefix matching, exact route matching, and keyword containment. + * + *

Attributes:

+ *
    + *
  • rules (required, List): + * A list of rules defining the cache clearing strategy. Each rule includes: + *
      + *
    • type (required, String): The type of matching rule. Supported values are: + *
        + *
      • prefix: Matches cache entries with keys that start with the specified prefix.
      • + *
      • exact: Matches cache entries with keys that exactly match the specified value.
      • + *
      • contains: Matches cache entries with keys that contain the specified substring + * .
      • + *
      + *
    • + *
    • value (required, String): The matching value for the rule. + *
        + *
      • For type "prefix", the value is the prefix path (e.g., "/feed/").
      • + *
      • For type "exact", the value is the exact route (e.g., "/feed/moments/rss.xml") + * .
      • + *
      • For type "contains", the value is a substring to search for (e.g., "moments").
      • + *
      + *
    • + *
    + *
  • + *
  • applyToAll (optional, boolean, default: false): + * Indicates whether to clear all cache entries. If true, all rules in the "rules" list are + * ignored, + * and the entire cache is cleared.
  • + *
+ */ +@SharedEvent +@Getter +public class RssCacheClearRequested extends ApplicationEvent { + private final List rules; + private final boolean applyToAll; + + public RssCacheClearRequested(Object source, List rules, boolean applyToAll) { + super(source); + this.rules = (rules == null ? List.of() : new ArrayList<>(rules)); + this.applyToAll = applyToAll; + } + + public static RssCacheClearRequested forAll(Object source) { + return new RssCacheClearRequested(source, null, true); + } + + public static RssCacheClearRequested forRules(Object source, List rules) { + return new RssCacheClearRequested(source, rules, false); + } + + public static RssCacheClearRequested forRule(Object source, CacheClearRule rule) { + List rules = new ArrayList<>(); + rules.add(rule); + return new RssCacheClearRequested(source, rules, false); + } +} diff --git a/api/src/main/java/run/halo/feed/RssRouteItem.java b/api/src/main/java/run/halo/feed/RssRouteItem.java new file mode 100644 index 0000000..9d079e3 --- /dev/null +++ b/api/src/main/java/run/halo/feed/RssRouteItem.java @@ -0,0 +1,40 @@ +package run.halo.feed; + +import org.pf4j.ExtensionPoint; +import org.springframework.lang.NonNull; +import org.springframework.web.reactive.function.server.ServerRequest; +import reactor.core.publisher.Mono; + +public interface RssRouteItem extends ExtensionPoint { + + /** + *

Path pattern of this route.

+ *

If return {@link Mono#empty()}, the route will be ignored.

+ *

Otherwise, the route will be registered with the returned path pattern by rule: {@code + * /feed/[namespace]/{pathPattern}}.

+ */ + Mono pathPattern(); + + @NonNull + String displayName(); + + default String description() { + return ""; + } + + /** + * An example URI for this route. + */ + default String example() { + return ""; + } + + /** + * The namespace of this route to avoid conflicts. + */ + default String namespace() { + return ""; + } + + Mono handler(ServerRequest request); +} diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..6ec86ca --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,34 @@ +plugins { + id 'java' + id "io.freefair.lombok" version "8.0.0-rc2" + id "run.halo.plugin.devtools" version "0.4.1" +} + +group = 'run.halo.feed' +version = rootProject.version + +jar { + dependsOn(":api:jar") +} + +dependencies { + implementation project(':api') + compileOnly 'run.halo.app:api' + implementation 'org.dom4j:dom4j:2.1.3' + implementation('org.apache.commons:commons-text:1.10.0') { + // Because the transitive dependency already exists in halo. + exclude group: 'org.apache.commons', module: 'commons-lang3' + } + + testImplementation 'run.halo.app:api' + testImplementation 'org.springframework.boot:spring-boot-starter-test' +} + +test { + useJUnitPlatform() +} + +halo { + version = '2.20.11' + debug = true +} \ No newline at end of file diff --git a/app/src/main/java/run/halo/feed/BasicProp.java b/app/src/main/java/run/halo/feed/BasicProp.java new file mode 100644 index 0000000..928d27a --- /dev/null +++ b/app/src/main/java/run/halo/feed/BasicProp.java @@ -0,0 +1,38 @@ +package run.halo.feed; + +import lombok.Data; +import lombok.experimental.Accessors; +import reactor.core.publisher.Mono; +import run.halo.app.plugin.ReactiveSettingFetcher; + +@Data +@Accessors(chain = true) +public class BasicProp { + private boolean enableCategories = Boolean.TRUE; + private boolean enableAuthors = Boolean.TRUE; + private String descriptionType; + private Integer outputNum; + private String rssExtraTags; + + public boolean isExcerptDescriptionType() { + return DescriptionType.EXCERPT.name().equalsIgnoreCase(descriptionType); + } + + public enum DescriptionType { + EXCERPT, + CONTENT + } + + public static Mono getBasicProp(ReactiveSettingFetcher settingFetcher) { + return settingFetcher.fetch("basic", BasicProp.class) + .defaultIfEmpty(new BasicProp()) + .doOnNext(prop -> { + if (prop.getOutputNum() == null) { + prop.setOutputNum(20); + } + if (prop.getDescriptionType() == null) { + prop.setDescriptionType(DescriptionType.EXCERPT.name()); + } + }); + } +} diff --git a/src/main/java/run/halo/feed/FeedPlugin.java b/app/src/main/java/run/halo/feed/FeedPlugin.java similarity index 100% rename from src/main/java/run/halo/feed/FeedPlugin.java rename to app/src/main/java/run/halo/feed/FeedPlugin.java diff --git a/app/src/main/java/run/halo/feed/FeedPluginEndpoint.java b/app/src/main/java/run/halo/feed/FeedPluginEndpoint.java new file mode 100644 index 0000000..ce1ada9 --- /dev/null +++ b/app/src/main/java/run/halo/feed/FeedPluginEndpoint.java @@ -0,0 +1,112 @@ +package run.halo.feed; + +import static org.springframework.web.reactive.function.server.RequestPredicates.accept; +import static org.springframework.web.reactive.function.server.RequestPredicates.path; + +import lombok.AllArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.lang.NonNull; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.server.HandlerFunction; +import org.springframework.web.reactive.function.server.RequestPredicate; +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.plugin.extensionpoint.ExtensionGetter; +import run.halo.feed.provider.PostRssProvider; + +@Component +@AllArgsConstructor +public class FeedPluginEndpoint { + static final RequestPredicate ACCEPT_PREDICATE = accept( + MediaType.TEXT_XML, + MediaType.APPLICATION_RSS_XML + ); + + private final PostRssProvider postRssProvider; + private final ExtensionGetter extensionGetter; + private final RssCacheManager rssCacheManager; + + @Bean + RouterFunction rssRouterFunction() { + return RouterFunctions.route() + .GET(path("/feed.xml").or(path("/rss.xml")).and(ACCEPT_PREDICATE), + request -> rssCacheManager.get("/rss.xml", postRssProvider.handler(request)) + .flatMap(this::buildResponse) + ) + .build(); + } + + @Bean + RouterFunction additionalRssRouter() { + var pathMatcher = ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, "/feed/**"); + return new RouterFunction<>() { + @Override + @NonNull + public Mono> route(@NonNull ServerRequest request) { + return pathMatcher.matches(request.exchange()) + .filter(ServerWebExchangeMatcher.MatchResult::isMatch) + .flatMap(matched -> handleRequest(request)); + } + + private Mono> handleRequest(ServerRequest request) { + return extensionGetter.getEnabledExtensions(RssRouteItem.class) + .concatMap(routeItem -> buildRequestPredicate(routeItem) + .map(requestPredicate -> new RouteItem(requestPredicate, + buildHandleFunction(routeItem)) + ) + ) + .filter(route -> route.requestPredicate.test(request)) + .next() + .map(RouteItem::handler); + } + + record RouteItem(RequestPredicate requestPredicate, + HandlerFunction handler) { + } + + private HandlerFunction buildHandleFunction(RssRouteItem routeItem) { + return request -> rssCacheManager.get(request.path(), routeItem.handler(request)) + .flatMap(item -> buildResponse(item)); + } + + private Mono buildRequestPredicate(RssRouteItem routeItem) { + return routeItem.pathPattern() + .map(pathPattern -> path( + buildPathPattern(pathPattern, routeItem.namespace())) + .and(ACCEPT_PREDICATE) + ); + } + + private String buildPathPattern(String pathPattern, String namespace) { + var sb = new StringBuilder("/feed/"); + + if (StringUtils.isNotBlank(namespace)) { + sb.append(namespace); + if (!namespace.endsWith("/")) { + sb.append("/"); + } + } + + if (pathPattern.startsWith("/")) { + pathPattern = pathPattern.substring(1); + } + sb.append(pathPattern); + + return sb.toString(); + } + }; + } + + private Mono buildResponse(String xml) { + return ServerResponse.ok().contentType(MediaType.TEXT_XML) + .bodyValue(xml); + } +} diff --git a/app/src/main/java/run/halo/feed/RssCacheManager.java b/app/src/main/java/run/halo/feed/RssCacheManager.java new file mode 100644 index 0000000..0705502 --- /dev/null +++ b/app/src/main/java/run/halo/feed/RssCacheManager.java @@ -0,0 +1,93 @@ +package run.halo.feed; + +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import java.time.Duration; +import java.util.function.Predicate; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; +import run.halo.app.event.post.PostUpdatedEvent; +import run.halo.app.infra.SystemInfoGetter; +import run.halo.app.plugin.PluginConfigUpdatedEvent; +import run.halo.app.plugin.ReactiveSettingFetcher; + +@Component +@RequiredArgsConstructor +public class RssCacheManager { + private final Duration expireMinutes = Duration.ofMinutes(60); + private final Cache cache = CacheBuilder.newBuilder() + .expireAfterWrite(expireMinutes) + .build(); + + private final SystemInfoGetter systemInfoGetter; + private final ReactiveSettingFetcher settingFetcher; + + public Mono get(String key, Mono loader) { + return Mono.fromCallable(() -> cache.get(key, () -> generateRssXml(loader) + .doOnNext(xml -> cache.put(key, xml)) + .block() + )) + .cache() + .subscribeOn(Schedulers.boundedElastic()); + } + + private Mono generateRssXml(Mono loader) { + var builder = new RssXmlBuilder(); + var rssMono = loader.doOnNext(builder::withRss2); + var generatorMono = getRssGenerator() + .doOnNext(builder::withGenerator); + var extractTagsMono = BasicProp.getBasicProp(settingFetcher) + .doOnNext(prop -> builder.withExtractRssTags(prop.getRssExtraTags())); + return Mono.when(rssMono, generatorMono, extractTagsMono) + .then(Mono.fromSupplier(builder::toXmlString)); + } + + private Mono getRssGenerator() { + return systemInfoGetter.get() + .map(info -> "Halo v" + info.getVersion().toStableVersion().toString()) + .defaultIfEmpty("Halo v2.0"); + } + + @EventListener(PluginConfigUpdatedEvent.class) + public void onPluginConfigUpdated() { + cache.invalidateAll(); + } + + @EventListener(RssCacheClearRequested.class) + public void onCacheClearRequested(RssCacheClearRequested event) { + if (event.isApplyToAll()) { + cache.invalidateAll(); + return; + } + event.getRules().forEach(rule -> { + switch (rule.type()) { + case PREFIX: + invalidateCache(key -> key.startsWith(rule.value())); + break; + case EXACT: + invalidateCache(key -> key.equals(rule.value())); + break; + case CONTAINS: + invalidateCache(key -> key.contains(rule.value())); + break; + default: + break; + } + }); + } + + @EventListener(PostUpdatedEvent.class) + public void onPostUpdated() { + invalidateCache(key -> key.equals("/rss.xml") + || key.startsWith("/feed/authors/") + || key.startsWith("/feed/categories/") + ); + } + + private void invalidateCache(Predicate predicate) { + cache.asMap().keySet().removeIf(predicate); + } +} diff --git a/app/src/main/java/run/halo/feed/RssXmlBuilder.java b/app/src/main/java/run/halo/feed/RssXmlBuilder.java new file mode 100644 index 0000000..a76bc39 --- /dev/null +++ b/app/src/main/java/run/halo/feed/RssXmlBuilder.java @@ -0,0 +1,212 @@ +package run.halo.feed; + +import com.google.common.base.Throwables; +import java.io.StringReader; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.dom4j.Document; +import org.dom4j.DocumentException; +import org.dom4j.DocumentHelper; +import org.dom4j.Element; +import org.dom4j.io.SAXReader; +import org.springframework.util.CollectionUtils; + +@Slf4j +public class RssXmlBuilder { + private RSS2 rss2; + private String generator = "Halo v2.0"; + private String extractRssTags; + private Instant lastBuildDate = Instant.now(); + + public RssXmlBuilder withRss2(RSS2 rss2) { + this.rss2 = rss2; + return this; + } + + /** + * For test. + */ + RssXmlBuilder withGenerator(String generator) { + this.generator = generator; + return this; + } + + public RssXmlBuilder withExtractRssTags(String extractRssTags) { + this.extractRssTags = extractRssTags; + return this; + } + + /** + * For test. + */ + RssXmlBuilder withLastBuildDate(Instant lastBuildDate) { + this.lastBuildDate = lastBuildDate; + return this; + } + + public String toXmlString() { + Document document = DocumentHelper.createDocument(); + + Element root = DocumentHelper.createElement("rss"); + root.addAttribute("version", "2.0"); + root.addNamespace("media", "http://search.yahoo.com/mrss/"); + document.setRootElement(root); + + Element channel = root.addElement("channel"); + channel.addElement("title").addText(rss2.getTitle()); + channel.addElement("link").addText(rss2.getLink()); + + var description = StringUtils.defaultIfBlank(rss2.getDescription(), rss2.getTitle()); + var secureDescription = XmlCharUtils.removeInvalidXmlChar(description); + channel.addElement("description").addText(secureDescription); + channel.addElement("generator").addText(generator); + + channel.addElement("language") + .addText(StringUtils.defaultIfBlank(rss2.getLanguage(), "zh-cn")); + + if (StringUtils.isNotBlank(rss2.getImage())) { + Element imageElement = channel.addElement("image"); + imageElement.addElement("url").addText(rss2.getImage()); + imageElement.addElement("title").addText(rss2.getTitle()); + imageElement.addElement("link").addText(rss2.getLink()); + } + channel.addElement("lastBuildDate") + .addText(instantToString(lastBuildDate)); + + if (StringUtils.isNotBlank(extractRssTags)) { + try { + var extractRssTagsElement = parseXmlString(extractRssTags); + for (Element element : extractRssTagsElement.elements()) { + Element newElement = channel.addElement(element.getName()); + copyAttributesAndChildren(newElement, element); + } + } catch (Throwable e) { + // ignore + log.error("无法注入自定义的 RSS 标签, 确保是正确的 XML 格式", + Throwables.getRootCause(e)); + } + } + + var items = rss2.getItems(); + createItemElementsToChannel(channel, items); + + return document.asXML(); + } + + private void copyAttributesAndChildren(Element target, Element source) { + for (var attribute : source.attributes()) { + target.addAttribute(attribute.getName(), attribute.getValue()); + } + if (source.getTextTrim() != null) { + target.setText(source.getTextTrim()); + } + for (Element child : source.elements()) { + Element newChild = target.addElement(child.getName()); + copyAttributesAndChildren(newChild, child); + } + } + + private Element parseXmlString(String xml) throws DocumentException { + // for SAXReader to load class from current classloader + var originalContextClassloader = Thread.currentThread().getContextClassLoader(); + Thread.currentThread().setContextClassLoader(this.getClass().getClassLoader()); + try { + SAXReader reader = new SAXReader(); + Document document = reader.read(new StringReader(""" + + %s + + """.formatted(xml))); + return document.getRootElement(); + } finally { + Thread.currentThread().setContextClassLoader(originalContextClassloader); + } + } + + private static void createItemElementsToChannel(Element channel, List items) { + if (CollectionUtils.isEmpty(items)) { + return; + } + items.forEach(item -> createItemElementToChannel(channel, item)); + } + + private static void createItemElementToChannel(Element channel, RSS2.Item item) { + Element itemElement = channel.addElement("item"); + itemElement.addElement("title").addCDATA(item.getTitle()); + itemElement.addElement("link").addText(item.getLink()); + itemElement.addElement("description").addCDATA(item.getDescription()); + itemElement.addElement("guid") + .addAttribute("isPermaLink", "false") + .addText(item.getGuid()); + + if (StringUtils.isNotBlank(item.getAuthor())) { + itemElement.addElement("author").addText(item.getAuthor()); + } + + if (StringUtils.isNotBlank(item.getEnclosureUrl())) { + itemElement.addElement("enclosure") + .addAttribute("url", item.getEnclosureUrl()) + .addAttribute("length", item.getEnclosureLength()) + .addAttribute("type", item.getEnclosureType()); + } + + nullSafeList(item.getCategories()) + .forEach(category -> itemElement.addElement("category").addText(category)); + + itemElement.addElement("pubDate") + .addText(instantToString(item.getPubDate())); + + // support for media:content + nullSafeList(item.getMediaContents()).forEach(mediaContent -> { + Element mediaElement = itemElement.addElement("media:content") + .addAttribute("url", mediaContent.getUrl()) + .addAttribute("type", mediaContent.getType()); + + if (mediaContent.getMediaType() != null) { + mediaElement.addAttribute("medium", + mediaContent.getMediaType().name().toLowerCase()); + } + + if (StringUtils.isNotBlank(mediaContent.getFileSize())) { + mediaElement.addAttribute("fileSize", mediaContent.getFileSize()); + } + if (StringUtils.isNotBlank(mediaContent.getDuration())) { + mediaElement.addAttribute("duration", mediaContent.getDuration()); + } + if (StringUtils.isNotBlank(mediaContent.getHeight())) { + mediaElement.addAttribute("height", mediaContent.getHeight()); + } + if (StringUtils.isNotBlank(mediaContent.getWidth())) { + mediaElement.addAttribute("width", mediaContent.getWidth()); + } + + // add nested media:thumbnail + var thumbnail = mediaContent.getThumbnail(); + if (thumbnail != null) { + Element thumbnailElement = mediaElement.addElement("media:thumbnail") + .addAttribute("url", thumbnail.getUrl()); + + if (StringUtils.isNotBlank(thumbnail.getHeight())) { + thumbnailElement.addAttribute("height", thumbnail.getHeight()); + } + + if (StringUtils.isNotBlank(thumbnail.getWidth())) { + thumbnailElement.addAttribute("width", thumbnail.getWidth()); + } + } + }); + } + + static List nullSafeList(List list) { + return list == null ? List.of() : list; + } + + static String instantToString(Instant instant) { + return instant.atOffset(ZoneOffset.UTC) + .format(DateTimeFormatter.RFC_1123_DATE_TIME); + } +} diff --git a/src/main/java/run/halo/feed/XmlCharUtils.java b/app/src/main/java/run/halo/feed/XmlCharUtils.java similarity index 100% rename from src/main/java/run/halo/feed/XmlCharUtils.java rename to app/src/main/java/run/halo/feed/XmlCharUtils.java diff --git a/app/src/main/java/run/halo/feed/provider/AbstractPostRssProvider.java b/app/src/main/java/run/halo/feed/provider/AbstractPostRssProvider.java new file mode 100644 index 0000000..cb9a5f8 --- /dev/null +++ b/app/src/main/java/run/halo/feed/provider/AbstractPostRssProvider.java @@ -0,0 +1,151 @@ +package run.halo.feed.provider; + +import java.util.List; +import java.util.function.Function; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.text.StringEscapeUtils; +import org.springframework.util.CollectionUtils; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.server.ServerWebInputException; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; +import run.halo.app.content.ContentWrapper; +import run.halo.app.content.PostContentService; +import run.halo.app.core.attachment.ThumbnailSize; +import run.halo.app.core.extension.User; +import run.halo.app.core.extension.content.Category; +import run.halo.app.core.extension.content.Post; +import run.halo.app.extension.ExtensionUtil; +import run.halo.app.extension.ListOptions; +import run.halo.app.extension.ListResult; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.extension.index.query.QueryFactory; +import run.halo.app.infra.ExternalLinkProcessor; +import run.halo.app.infra.ExternalUrlSupplier; +import run.halo.app.infra.SystemInfoGetter; +import run.halo.app.plugin.ReactiveSettingFetcher; +import run.halo.feed.BasicProp; +import run.halo.feed.RSS2; +import run.halo.feed.service.PostService; + +@RequiredArgsConstructor +public abstract class AbstractPostRssProvider { + protected final PostService postService; + protected final ReactiveExtensionClient client; + protected final ExternalLinkProcessor externalLinkProcessor; + protected final ReactiveSettingFetcher settingFetcher; + protected final PostContentService postContentService; + protected final ExternalUrlSupplier externalUrlSupplier; + protected final SystemInfoGetter systemInfoGetter; + + public Mono handler(ServerRequest request) { + var builder = RSS2.builder(); + var rssMono = systemInfoGetter.get() + .doOnNext(systemInfo -> builder + .title(systemInfo.getTitle()) + .image(externalLinkProcessor.processLink(systemInfo.getLogo())) + .description( + StringUtils.defaultIfBlank(systemInfo.getSubtitle(), systemInfo.getTitle())) + .link(externalUrlSupplier.getURL(request.exchange().getRequest()).toString()) + ) + .subscribeOn(Schedulers.boundedElastic()); + + var itemsMono = listPosts(request) + .concatMap(postWithContent -> { + var post = postWithContent.getPost(); + var permalink = post.getStatusOrDefault().getPermalink(); + var itemBuilder = RSS2.Item.builder() + .title(post.getSpec().getTitle()) + .link(externalLinkProcessor.processLink(permalink)) + .pubDate(post.getSpec().getPublishTime()) + .guid(post.getStatusOrDefault().getPermalink()) + .description(postWithContent.getContent()); + + if (StringUtils.isNotBlank(post.getSpec().getCover())) { + itemBuilder.enclosureUrl(genThumbUrl(post.getSpec().getCover())) + .enclosureType("image/jpeg"); + } + + var ownerName = post.getSpec().getOwner(); + var userMono = fetchUser(ownerName) + .map(user -> user.getSpec().getDisplayName()) + .doOnNext(itemBuilder::author); + + var categoryMono = fetchCategoryDisplayName(post.getSpec().getCategories()) + .collectList() + .doOnNext(itemBuilder::categories); + + return Mono.when(userMono, categoryMono) + .then(Mono.fromSupplier(itemBuilder::build)); + }) + .collectList() + .doOnNext(builder::items) + .subscribeOn(Schedulers.boundedElastic()); + + return Mono.when(rssMono, itemsMono) + .then(Mono.fromSupplier(builder::build)); + } + + private String genThumbUrl(String url) { + return externalLinkProcessor.processLink( + "/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=" + url + "&size=" + + ThumbnailSize.M.name().toLowerCase() + ); + } + + protected Flux listPosts(ServerRequest request) { + return listPostsByFunc(basicProp -> postService.listPosts(basicProp.getOutputNum()) + .flatMapIterable(ListResult::getItems)); + } + + protected Flux listPostsByFunc(Function> postListFunc) { + return BasicProp.getBasicProp(settingFetcher) + .flatMapMany(basicProp -> postListFunc.apply(basicProp) + .concatMap(post -> { + var postWithContent = new PostWithContent() + .setPost(post); + if (basicProp.isExcerptDescriptionType()) { + // Prevent parsing excerpt as html + var escapedContent = + StringEscapeUtils.escapeXml10(post.getStatusOrDefault().getExcerpt()); + postWithContent.setContent(escapedContent); + return Mono.just(postWithContent); + } + return postContentService.getReleaseContent(post.getMetadata().getName()) + .map(ContentWrapper::getContent) + .doOnNext(content -> postWithContent.setContent( + StringUtils.defaultString(content)) + ) + .thenReturn(postWithContent); + }) + ); + } + + protected Mono fetchUser(String username) { + return client.fetch(User.class, username) + .switchIfEmpty(Mono.error(new ServerWebInputException("User not found"))) + .subscribeOn(Schedulers.boundedElastic()); + } + + private Flux fetchCategoryDisplayName(List categoryNames) { + if (CollectionUtils.isEmpty(categoryNames)) { + return Flux.empty(); + } + return client.listAll(Category.class, ListOptions.builder() + .fieldQuery(QueryFactory.in("metadata.name", categoryNames)) + .build(), ExtensionUtil.defaultSort()) + .map(category -> category.getSpec().getDisplayName()) + .subscribeOn(Schedulers.boundedElastic()); + } + + @Data + @Accessors(chain = true) + protected static class PostWithContent { + private Post post; + private String content; + } +} diff --git a/app/src/main/java/run/halo/feed/provider/AuthorPostRssProvider.java b/app/src/main/java/run/halo/feed/provider/AuthorPostRssProvider.java new file mode 100644 index 0000000..681b7f2 --- /dev/null +++ b/app/src/main/java/run/halo/feed/provider/AuthorPostRssProvider.java @@ -0,0 +1,80 @@ +package run.halo.feed.provider; + +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.server.ServerRequest; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import run.halo.app.content.PostContentService; +import run.halo.app.extension.ListResult; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.ExternalLinkProcessor; +import run.halo.app.infra.ExternalUrlSupplier; +import run.halo.app.infra.SystemInfoGetter; +import run.halo.app.plugin.ReactiveSettingFetcher; +import run.halo.feed.BasicProp; +import run.halo.feed.RSS2; +import run.halo.feed.RssRouteItem; +import run.halo.feed.service.PostService; + +@Component +public class AuthorPostRssProvider extends AbstractPostRssProvider implements RssRouteItem { + + public AuthorPostRssProvider(PostService postService, ReactiveExtensionClient client, + ExternalLinkProcessor externalLinkProcessor, ReactiveSettingFetcher settingFetcher, + PostContentService postContentService, ExternalUrlSupplier externalUrlSupplier, + SystemInfoGetter systemInfoGetter) { + super(postService, client, externalLinkProcessor, settingFetcher, postContentService, + externalUrlSupplier, systemInfoGetter); + } + + @Override + public Mono pathPattern() { + return BasicProp.getBasicProp(settingFetcher) + .filter(BasicProp::isEnableAuthors) + .map(prop -> "/authors/{author}.xml"); + } + + @Override + @NonNull + public String displayName() { + return "作者文章订阅"; + } + + @Override + public String description() { + return "按作者订阅站点文章"; + } + + @Override + public String example() { + return "https://exmaple.com/feed/authors/fake.xml"; + } + + @Override + public Mono handler(ServerRequest request) { + var authorName = request.pathVariable("author"); + return super.handler(request) + .flatMap(rss2 -> fetchUser(authorName) + .doOnNext(author -> { + var displayName = author.getSpec().getDisplayName(); + rss2.setTitle("作者:" + displayName + " - " + rss2.getTitle()); + + if (author.getStatus() != null) { + rss2.setLink( + externalLinkProcessor.processLink(author.getStatus().getPermalink())); + } + }) + .thenReturn(rss2) + ); + } + + @Override + protected Flux listPosts(ServerRequest request) { + return listPostsByFunc(basicProp -> { + var author = request.pathVariable("author"); + return postService.listPostByAuthor(basicProp.getOutputNum(), author) + .flatMapIterable(ListResult::getItems); + }); + } +} diff --git a/app/src/main/java/run/halo/feed/provider/CategoryPostRssProvider.java b/app/src/main/java/run/halo/feed/provider/CategoryPostRssProvider.java new file mode 100644 index 0000000..17f74f5 --- /dev/null +++ b/app/src/main/java/run/halo/feed/provider/CategoryPostRssProvider.java @@ -0,0 +1,93 @@ +package run.halo.feed.provider; + +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.server.ServerWebInputException; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import run.halo.app.content.PostContentService; +import run.halo.app.core.extension.content.Category; +import run.halo.app.extension.ListOptions; +import run.halo.app.extension.ListResult; +import run.halo.app.extension.PageRequestImpl; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.extension.index.query.QueryFactory; +import run.halo.app.infra.ExternalLinkProcessor; +import run.halo.app.infra.ExternalUrlSupplier; +import run.halo.app.infra.SystemInfoGetter; +import run.halo.app.plugin.ReactiveSettingFetcher; +import run.halo.feed.BasicProp; +import run.halo.feed.RSS2; +import run.halo.feed.RssRouteItem; +import run.halo.feed.service.PostService; + +@Component +public class CategoryPostRssProvider extends AbstractPostRssProvider implements RssRouteItem { + + public CategoryPostRssProvider(PostService postService, ReactiveExtensionClient client, + ExternalLinkProcessor externalLinkProcessor, ReactiveSettingFetcher settingFetcher, + PostContentService postContentService, ExternalUrlSupplier externalUrlSupplier, + SystemInfoGetter systemInfoGetter) { + super(postService, client, externalLinkProcessor, settingFetcher, postContentService, + externalUrlSupplier, systemInfoGetter); + } + + @Override + public Mono pathPattern() { + return BasicProp.getBasicProp(settingFetcher) + .filter(BasicProp::isEnableCategories) + .map(prop -> "/categories/{slug}.xml"); + } + + @Override + @NonNull + public String displayName() { + return "分类文章订阅"; + } + + @Override + public String description() { + return "按分类订阅站点文章"; + } + + @Override + public String example() { + return "https://exmaple.com/feed/categories/fake.xml"; + } + + @Override + public Mono handler(ServerRequest request) { + var slug = request.pathVariable("slug"); + return super.handler(request) + .flatMap(rss2 -> getCategoryDisplayName(slug) + .doOnNext(category -> { + var displayName = category.getSpec().getDisplayName(); + var permalink = category.getStatusOrDefault().getPermalink(); + rss2.setTitle("分类:" + displayName + " - " + rss2.getTitle()); + rss2.setLink(externalLinkProcessor.processLink(permalink)); + }) + .thenReturn(rss2) + ); + } + + private Mono getCategoryDisplayName(String categorySlug) { + return client.listBy(Category.class, ListOptions.builder() + .fieldQuery(QueryFactory.equal("spec.slug", categorySlug)) + .build(), + PageRequestImpl.ofSize(1) + ) + .flatMapIterable(ListResult::getItems) + .next() + .switchIfEmpty(Mono.error(new ServerWebInputException("Category not found"))); + } + + @Override + protected Flux listPosts(ServerRequest request) { + var categorySlug = request.pathVariable("slug"); + return listPostsByFunc(basicProp -> + postService.listPostByCategorySlug(basicProp.getOutputNum(), categorySlug) + .flatMapIterable(ListResult::getItems) + ); + } +} diff --git a/app/src/main/java/run/halo/feed/provider/PostRssProvider.java b/app/src/main/java/run/halo/feed/provider/PostRssProvider.java new file mode 100644 index 0000000..b0712aa --- /dev/null +++ b/app/src/main/java/run/halo/feed/provider/PostRssProvider.java @@ -0,0 +1,22 @@ +package run.halo.feed.provider; + +import org.springframework.stereotype.Component; +import run.halo.app.content.PostContentService; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.ExternalLinkProcessor; +import run.halo.app.infra.ExternalUrlSupplier; +import run.halo.app.infra.SystemInfoGetter; +import run.halo.app.plugin.ReactiveSettingFetcher; +import run.halo.feed.service.PostService; + +@Component +public class PostRssProvider extends AbstractPostRssProvider { + + public PostRssProvider(PostService postService, ReactiveExtensionClient client, + ExternalLinkProcessor externalLinkProcessor, ReactiveSettingFetcher settingFetcher, + PostContentService postContentService, ExternalUrlSupplier externalUrlSupplier, + SystemInfoGetter systemInfoGetter) { + super(postService, client, externalLinkProcessor, settingFetcher, postContentService, + externalUrlSupplier, systemInfoGetter); + } +} diff --git a/app/src/main/java/run/halo/feed/service/PostService.java b/app/src/main/java/run/halo/feed/service/PostService.java new file mode 100644 index 0000000..befc542 --- /dev/null +++ b/app/src/main/java/run/halo/feed/service/PostService.java @@ -0,0 +1,16 @@ +package run.halo.feed.service; + +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; + +public interface PostService { + Mono> listPosts(int size); + + Mono> listPostByCategorySlug(int size, String categorySlug); + + Mono> listPostByAuthor(int size, String author); + + Mono getCategoryBySlug(String categorySlug); +} diff --git a/app/src/main/java/run/halo/feed/service/PostServiceImpl.java b/app/src/main/java/run/halo/feed/service/PostServiceImpl.java new file mode 100644 index 0000000..599cf1c --- /dev/null +++ b/app/src/main/java/run/halo/feed/service/PostServiceImpl.java @@ -0,0 +1,84 @@ +package run.halo.feed.service; + +import static run.halo.app.extension.index.query.QueryFactory.and; +import static run.halo.app.extension.index.query.QueryFactory.equal; +import static run.halo.app.extension.index.query.QueryFactory.in; +import static run.halo.app.extension.index.query.QueryFactory.isNull; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; +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.ListOptions; +import run.halo.app.extension.ListResult; +import run.halo.app.extension.PageRequestImpl; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.extension.index.query.Query; + +@Component +@RequiredArgsConstructor +public class PostServiceImpl implements PostService { + private final ReactiveExtensionClient client; + + @Override + public Mono> listPosts(int size) { + return client.listBy(Post.class, buildPostListOptions(), + PageRequestImpl.ofSize(size).withSort(defaultSort())); + } + + @Override + public Mono> listPostByCategorySlug(int size, String categorySlug) { + return getCategoryBySlug(categorySlug) + .flatMap(category -> { + var categoryName = category.getMetadata().getName(); + return client.listBy(Post.class, + buildPostListOptions(in("spec.categories", categoryName)), + PageRequestImpl.ofSize(size).withSort(defaultSort())); + }); + } + + @Override + public Mono> listPostByAuthor(int size, String author) { + return client.listBy(Post.class, buildPostListOptions(in("spec.owner", author)), + PageRequestImpl.ofSize(size).withSort(defaultSort())); + } + + @Override + public Mono getCategoryBySlug(String categorySlug) { + return client.listBy(Category.class, ListOptions.builder() + .fieldQuery(equal("spec.slug", categorySlug)) + .build(), + PageRequestImpl.ofSize(1) + ) + .flatMap(listResult -> Mono.justOrEmpty(ListResult.first(listResult))); + } + + ListOptions buildPostListOptions() { + return buildPostListOptions(null); + } + + ListOptions buildPostListOptions(Query query) { + var builder = ListOptions.builder() + .labelSelector() + .eq(Post.PUBLISHED_LABEL, "true") + .end() + .fieldQuery(and( + isNull("metadata.deletionTimestamp"), + equal("spec.deleted", "false"), + equal("spec.visible", Post.VisibleEnum.PUBLIC.name()) + )); + if (query != null) { + builder.fieldQuery(query); + } + return builder.build(); + } + + static Sort defaultSort() { + return Sort.by( + Sort.Order.desc("spec.publishTime"), + Sort.Order.asc("metadata.name") + ); + } +} diff --git a/app/src/main/resources/extensions/ext-definition.yaml b/app/src/main/resources/extensions/ext-definition.yaml new file mode 100644 index 0000000..4e704de --- /dev/null +++ b/app/src/main/resources/extensions/ext-definition.yaml @@ -0,0 +1,30 @@ +apiVersion: plugin.halo.run/v1alpha1 +kind: ExtensionPointDefinition +metadata: + name: feed-rss-route-item +spec: + className: run.halo.feed.RssRouteItem + displayName: "RSS 订阅源" + description: "用于扩展 RSS 订阅源的生成" + type: MULTI_INSTANCE + icon: "/plugins/PluginFeed/assets/logo.svg" +--- +apiVersion: plugin.halo.run/v1alpha1 +kind: ExtensionDefinition +metadata: + name: feed-category-post-rss-item +spec: + className: run.halo.feed.provider.CategoryPostRssProvider + extensionPointName: feed-rss-route-item + displayName: "分类文章订阅" + description: "用于生成按照分类订阅文章的 RSS 订阅源" +--- +apiVersion: plugin.halo.run/v1alpha1 +kind: ExtensionDefinition +metadata: + name: feed-author-post-rss-item +spec: + className: run.halo.feed.provider.AuthorPostRssProvider + extensionPointName: feed-rss-route-item + displayName: "作者文章订阅" + description: "用于生成按照作者订阅文章的 RSS 订阅源" diff --git a/src/main/resources/extensions/settings.yaml b/app/src/main/resources/extensions/settings.yaml similarity index 84% rename from src/main/resources/extensions/settings.yaml rename to app/src/main/resources/extensions/settings.yaml index 93728b8..6ee5bdb 100644 --- a/src/main/resources/extensions/settings.yaml +++ b/app/src/main/resources/extensions/settings.yaml @@ -32,3 +32,9 @@ spec: label: 内容输出条数 value: 20 validation: required|number|min:1 + - $formkit: code + name: rssExtraTags + label: 注入额外的 RSS 标签 + value: "" + height: 150px + language: html diff --git a/src/main/resources/logo.svg b/app/src/main/resources/logo.svg similarity index 100% rename from src/main/resources/logo.svg rename to app/src/main/resources/logo.svg diff --git a/src/main/resources/plugin.yaml b/app/src/main/resources/plugin.yaml similarity index 94% rename from src/main/resources/plugin.yaml rename to app/src/main/resources/plugin.yaml index b9cfcdc..fa5c6be 100644 --- a/src/main/resources/plugin.yaml +++ b/app/src/main/resources/plugin.yaml @@ -8,8 +8,7 @@ metadata: "store.halo.run/app-id": "app-KhIVw" spec: enabled: true - version: 1.1.1 - requires: ">=2.10.0" + requires: ">=2.20.11" author: name: Halo website: https://github.com/halo-dev diff --git a/app/src/test/java/run/halo/feed/RSS2Test.java b/app/src/test/java/run/halo/feed/RSS2Test.java new file mode 100644 index 0000000..7804de4 --- /dev/null +++ b/app/src/test/java/run/halo/feed/RSS2Test.java @@ -0,0 +1,132 @@ +package run.halo.feed; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.Arrays; +import org.junit.jupiter.api.Test; + +class RSS2Test { + + @Test + void toXmlString() { + var rss = RSS2.builder() + .title("title") + .description("description") + .link("link") + .items(Arrays.asList( + RSS2.Item.builder() + .title("title1") + .description("description1") + .link("link1") + .pubDate(Instant.EPOCH) + .guid("guid1") + .build(), + RSS2.Item.builder() + .title("title2") + .description("description2") + .link("link2") + .pubDate(Instant.ofEpochSecond(2208988800L)) + .guid("guid2") + .build() + )) + .build(); + + var instant = Instant.now(); + var rssXml = new RssXmlBuilder() + .withRss2(rss) + .withGenerator("Halo") + .withLastBuildDate(instant) + .toXmlString(); + + var lastBuildDate = RssXmlBuilder.instantToString(instant); + + // language=xml + var expected = """ + + + + title + link + description + Halo + zh-cn + %s + + + <![CDATA[title1]]> + + link1 + + + + guid1 + Thu, 1 Jan 1970 00:00:00 GMT + + + + <![CDATA[title2]]> + + link2 + + + + guid2 + Sun, 1 Jan 2040 00:00:00 GMT + + + + """.formatted(lastBuildDate); + assertThat(rssXml).isEqualToIgnoringWhitespace(expected); + } + + @Test + void extractRssTagsTest() { + var instant = Instant.now(); + var rss = RSS2.builder() + .title("title") + .description("description") + .link("link") + .build(); + + var lastBuildDate = RssXmlBuilder.instantToString(instant); + var rssXml = new RssXmlBuilder() + .withRss2(rss) + .withGenerator("Halo") + .withExtractRssTags(""" + + John + 20 + + + Tom + 30 + + """) + .toXmlString(); + // language=xml + var expected = """ + + + + title + link + description + Halo + zh-cn + %s + + John + 20 + + + Tom + 30 + + + + """.formatted(lastBuildDate); + assertThat(rssXml).isEqualToIgnoringWhitespace(expected); + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index 3bfc5ce..cf01d1c 100644 --- a/build.gradle +++ b/build.gradle @@ -1,34 +1,12 @@ plugins { - id "io.freefair.lombok" version "8.0.0-rc2" - id "run.halo.plugin.devtools" version "0.0.9" id 'java' } -group 'run.halo.feed' -sourceCompatibility = JavaVersion.VERSION_17 - -repositories { - mavenCentral() - maven { url 'https://s01.oss.sonatype.org/content/repositories/releases' } - maven { url 'https://s01.oss.sonatype.org/content/repositories/snapshots/' } - maven { url 'https://repo.spring.io/milestone' } -} - -dependencies { - implementation 'org.dom4j:dom4j:2.1.3' - implementation('org.apache.commons:commons-text:1.10.0') { - // Because the transitive dependency already exists in halo. - exclude group: 'org.apache.commons', module: 'commons-lang3' +allprojects { + repositories { + mavenCentral() + maven { url 'https://s01.oss.sonatype.org/content/repositories/releases' } + maven { url 'https://s01.oss.sonatype.org/content/repositories/snapshots/' } + maven { url 'https://repo.spring.io/milestone' } } - - implementation platform('run.halo.tools.platform:plugin:2.10.0-SNAPSHOT') - compileOnly 'run.halo.app:api' - - testImplementation 'run.halo.app:api' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - -} - -test { - useJUnitPlatform() } diff --git a/settings.gradle b/settings.gradle index b70863a..043ed59 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,11 +1,10 @@ pluginManagement { repositories { - maven { url 'https://s01.oss.sonatype.org/content/repositories/snapshots' } - maven { url 'https://maven.aliyun.com/repository/gradle-plugin' } - maven { url 'https://maven.aliyun.com/repository/spring-plugin' } - maven { url 'https://repo.spring.io/milestone' } + mavenCentral() gradlePluginPortal() } } rootProject.name = 'plugin-feed' +include 'api' +include 'app' diff --git a/src/main/java/run/halo/feed/BasicSetting.java b/src/main/java/run/halo/feed/BasicSetting.java deleted file mode 100644 index 7cc269f..0000000 --- a/src/main/java/run/halo/feed/BasicSetting.java +++ /dev/null @@ -1,32 +0,0 @@ -package run.halo.feed; - - -import lombok.Data; - -@Data -public class BasicSetting { - public static final String CONFIG_MAP_NAME = "plugin-feed-config"; - public static final String GROUP = "basic"; - - private Boolean enableCategories = Boolean.TRUE; - - private Boolean enableAuthors = Boolean.TRUE; - - private DescriptionType descriptionType = DescriptionType.excerpt; - - private Integer outputNum = 20; - - - enum DescriptionType { - /** - * 全文 - */ - content, - - /** - * 摘要 - */ - excerpt - - } -} diff --git a/src/main/java/run/halo/feed/ContentWrapper.java b/src/main/java/run/halo/feed/ContentWrapper.java deleted file mode 100644 index 0a30ec7..0000000 --- a/src/main/java/run/halo/feed/ContentWrapper.java +++ /dev/null @@ -1,43 +0,0 @@ -package run.halo.feed; - -import lombok.Builder; -import lombok.Data; -import org.apache.commons.lang3.StringUtils; -import org.springframework.util.Assert; -import run.halo.app.core.extension.content.Snapshot; - -/** - * Copy from here. - */ -@Data -@Builder -public class ContentWrapper { - - private String snapshotName; - private String raw; - private String content; - private String rawType; - - public static ContentWrapper patchSnapshot(Snapshot patchSnapshot, Snapshot baseSnapshot) { - Assert.notNull(baseSnapshot, "The baseSnapshot must not be null."); - String baseSnapshotName = baseSnapshot.getMetadata().getName(); - if (StringUtils.equals(patchSnapshot.getMetadata().getName(), baseSnapshotName)) { - return ContentWrapper.builder() - .snapshotName(patchSnapshot.getMetadata().getName()) - .raw(patchSnapshot.getSpec().getRawPatch()) - .content(patchSnapshot.getSpec().getContentPatch()) - .rawType(patchSnapshot.getSpec().getRawType()) - .build(); - } - String patchedContent = PatchUtils.applyPatch(baseSnapshot.getSpec().getContentPatch(), - patchSnapshot.getSpec().getContentPatch()); - String patchedRaw = PatchUtils.applyPatch(baseSnapshot.getSpec().getRawPatch(), - patchSnapshot.getSpec().getRawPatch()); - return ContentWrapper.builder() - .snapshotName(patchSnapshot.getMetadata().getName()) - .raw(patchedRaw) - .content(patchedContent) - .rawType(patchSnapshot.getSpec().getRawType()) - .build(); - } -} diff --git a/src/main/java/run/halo/feed/FeedPluginEndpoint.java b/src/main/java/run/halo/feed/FeedPluginEndpoint.java deleted file mode 100644 index 46a242d..0000000 --- a/src/main/java/run/halo/feed/FeedPluginEndpoint.java +++ /dev/null @@ -1,40 +0,0 @@ -package run.halo.feed; - -import lombok.AllArgsConstructor; -import org.springframework.context.annotation.Bean; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Component; -import org.springframework.web.reactive.function.server.RequestPredicate; -import org.springframework.web.reactive.function.server.RouterFunction; -import org.springframework.web.reactive.function.server.RouterFunctions; -import org.springframework.web.reactive.function.server.ServerResponse; - -import static org.springframework.web.reactive.function.server.RequestPredicates.GET; -import static org.springframework.web.reactive.function.server.RequestPredicates.accept; - -/** - * Feed plugin endpoint. - * Router function configuration. - */ -@Component -@AllArgsConstructor -public class FeedPluginEndpoint { - private final FeedService feedService; - - - @Bean - RouterFunction sitemapRouterFunction() { - RequestPredicate requestPredicate = accept( - MediaType.TEXT_XML, - MediaType.APPLICATION_RSS_XML - ); - return RouterFunctions.route(GET("/feed.xml").and(requestPredicate), - feedService::allFeed) - .andRoute(GET("/rss.xml").and(requestPredicate), - feedService::allFeed) - .andRoute(GET("/feed/categories/{category}.xml").and(requestPredicate), - request -> feedService.categoryFeed(request, request.pathVariable("category"))) - .andRoute(GET("/feed/authors/{author}.xml").and(requestPredicate), - request -> feedService.authorFeed(request, request.pathVariable("author"))); - } -} \ No newline at end of file diff --git a/src/main/java/run/halo/feed/FeedService.java b/src/main/java/run/halo/feed/FeedService.java deleted file mode 100644 index f7eed88..0000000 --- a/src/main/java/run/halo/feed/FeedService.java +++ /dev/null @@ -1,32 +0,0 @@ -package run.halo.feed; - -import org.springframework.web.reactive.function.server.ServerRequest; -import org.springframework.web.reactive.function.server.ServerResponse; -import reactor.core.publisher.Mono; - -public interface FeedService { - /** - * Get all posts feed response - * - * @return feed response - */ - Mono allFeed(ServerRequest request); - - /** - * Get category posts feed response - * - * @param request - * @param category category name - * @return feed response - */ - Mono categoryFeed(ServerRequest request, String category); - - /** - * Get author posts feed response - * - * @param request - * @param author author metadata name - * @return feed response - */ - Mono authorFeed(ServerRequest request, String author); -} diff --git a/src/main/java/run/halo/feed/FeedServiceImpl.java b/src/main/java/run/halo/feed/FeedServiceImpl.java deleted file mode 100644 index b0c0966..0000000 --- a/src/main/java/run/halo/feed/FeedServiceImpl.java +++ /dev/null @@ -1,218 +0,0 @@ -package run.halo.feed; - - -import lombok.AllArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.BooleanUtils; -import org.apache.commons.text.StringEscapeUtils; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Service; -import org.springframework.util.Assert; -import org.springframework.util.StringUtils; -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.content.Post; -import run.halo.app.extension.ListResult; -import run.halo.app.infra.ExternalUrlSupplier; -import run.halo.app.infra.SystemSetting; - -import java.net.URI; -import java.net.URISyntaxException; -import java.net.URL; -import java.util.Objects; - -@Service -@AllArgsConstructor -@Slf4j -public class FeedServiceImpl implements FeedService { - private final ReactiveSettingFetcher settingFetcher; - - private final ExternalUrlSupplier externalUrlSupplier; - - private final FeedSourceFinder feedSourceFinder; - - private static final int FIRST_PAGE = 1; - - @Override - public Mono allFeed(ServerRequest request) { - return getFeedContext(request) - .flatMap(feedContext -> { - var rss2 = buildBaseRss(feedContext); - - return postListResultToXmlServerResponse( - feedSourceFinder.listPosts(FIRST_PAGE, - feedContext.basicPluginSetting.getOutputNum()), - feedContext, rss2); - }); - } - - @Override - public Mono categoryFeed(ServerRequest request, String category) { - return getFeedContext(request) - .filter(feedContext -> BooleanUtils.isTrue( - feedContext.basicPluginSetting.getEnableCategories())) - .flatMap(feedContext -> { - var rss2 = buildBaseRss(feedContext); - // Get category metadata name by category slug - return feedSourceFinder.getCategoriesContentBySlug(category) - .next() - .map(categoryContent -> { - // Set category info - var permalink = categoryContent.getStatusOrDefault().getPermalink(); - if (permalink != null) { - var permalinkUri = URI.create(permalink); - if (!permalinkUri.isAbsolute()) { - permalinkUri = - request.exchange().getRequest().getURI().resolve(permalinkUri); - } - permalink = permalinkUri.toString(); - } - rss2.setTitle("分类:" + categoryContent.getSpec().getDisplayName() + - " - " + rss2.getTitle()); - rss2.setLink(permalink); - if (StringUtils.hasText(categoryContent.getSpec().getDescription())) { - rss2.setDescription(categoryContent.getSpec().getDescription()); - } - return categoryContent.getMetadata().getName(); - }) - .flatMap(categoryMetadataName -> { - // Get posts by category metadata name - var listResultMono = feedSourceFinder.listPostsByCategory( - FIRST_PAGE, - feedContext.basicPluginSetting.getOutputNum(), - categoryMetadataName); - return postListResultToXmlServerResponse(listResultMono, feedContext, rss2); - }); - }) - .onErrorResume(error -> { - log.error("Failed to get category feed", error); - return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); - }) - .switchIfEmpty(ServerResponse.notFound().build()); - } - - @Override - public Mono authorFeed(ServerRequest request, String author) { - return getFeedContext(request) - .filter(feedContext -> BooleanUtils.isTrue( - feedContext.basicPluginSetting.getEnableAuthors())) - .flatMap(feedContext -> { - var rss2 = buildBaseRss(feedContext); - // Get author display name by author metadata name - return feedSourceFinder.getUserByName(author) - .flatMap(user -> { - rss2.setTitle( - "作者:" + user.getSpec().getDisplayName() + " - " + rss2.getTitle()); - // TODO author link need upgrade halo dependency version - - return postListResultToXmlServerResponse( - feedSourceFinder.listPostsByAuthor(FIRST_PAGE, - feedContext.basicPluginSetting.getOutputNum(), author), - feedContext, rss2); - }); - }) - .onErrorResume(error -> { - log.error("Failed to get author feed", error); - return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); - }) - .switchIfEmpty(ServerResponse.notFound().build()); - } - - private Mono getFeedContext(ServerRequest request) { - return Mono - .zip(settingFetcher.fetch(BasicSetting.CONFIG_MAP_NAME, BasicSetting.GROUP, - BasicSetting.class) - .switchIfEmpty(Mono.just(new BasicSetting())), - settingFetcher.fetch(SystemSetting.SYSTEM_CONFIG, SystemSetting.Basic.GROUP, - SystemSetting.Basic.class)) - .map(tuple -> { - var basicPluginSetting = tuple.getT1(); - var systemBasicSetting = tuple.getT2(); - // Assert basic plugin setting - Assert.notNull(basicPluginSetting.getOutputNum(), "outputNum cannot be null"); - Assert.isTrue(basicPluginSetting.getOutputNum() > 0, - "OutputNum must be greater than zero"); - Assert.notNull(basicPluginSetting.getDescriptionType(), - "descriptionType cannot be null"); - - var externalUrl = externalUrlSupplier.getRaw(); - if (externalUrl == null) { - externalUrl = externalUrlSupplier.getURL(request.exchange().getRequest()); - } - // Build feed context - return new FeedContext(basicPluginSetting, systemBasicSetting, externalUrl); - }); - } - - private Mono postListResultToXmlServerResponse( - Mono> postListResult, - FeedContext feedContext, RSS2 rss2) { - return postListResult - .flatMapIterable(ListResult::getItems) - .concatMap(post -> { - // Create item - var permalink = post.getStatusOrDefault().getPermalink(); - if (permalink != null) { - var permalinkUri = URI.create(permalink); - if (!permalinkUri.isAbsolute()) { - try { - permalinkUri = feedContext.externalUrl.toURI().resolve(permalinkUri); - } catch (URISyntaxException e) { - throw new RuntimeException(e); - } - } - permalink = permalinkUri.toString(); - } - var itemBuilder = RSS2.Item.builder() - .title(post.getSpec().getTitle()) - .link(permalink) - .pubDate(post.getSpec().getPublishTime()) - .guid(post.getStatusOrDefault().getPermalink()); - - // TODO lastBuildDate need upgrade halo dependency version - - // Set description - if (Objects.equals(feedContext.basicPluginSetting.getDescriptionType(), - BasicSetting.DescriptionType.content)) { - // Set releaseSnapshot as description - var releaseSnapshot = post.getSpec().getReleaseSnapshot(); - var baseSnapshot = post.getSpec().getBaseSnapshot(); - return feedSourceFinder.getPostContent(releaseSnapshot, baseSnapshot) - .map(contentWrapper -> itemBuilder - .description( - XmlCharUtils.removeInvalidXmlChar(contentWrapper.getContent())) - .build()); - } else { - // Set excerpt as description - return Mono.just(itemBuilder - // Prevent parsing excerpt as html - // escapeXml10 already remove invalid characters - .description( - StringEscapeUtils.escapeXml10(post.getStatusOrDefault().getExcerpt())) - .build()); - } - }) - .collectList() - .map(items -> { - rss2.setItems(items); - return rss2.toXmlString(); - }) - .flatMap(xml -> ServerResponse.ok().contentType(MediaType.TEXT_XML).bodyValue(xml)); - } - - private RSS2 buildBaseRss(FeedContext feedContext) { - return RSS2.builder() - .title(feedContext.systemBasicSetting.getTitle()) - .description(StringUtils.hasText(feedContext.systemBasicSetting.getSubtitle()) ? - feedContext.systemBasicSetting.getSubtitle() : - feedContext.systemBasicSetting.getTitle()) - .link(feedContext.externalUrl.toString()) - .build(); - } - - record FeedContext(BasicSetting basicPluginSetting, SystemSetting.Basic systemBasicSetting, - URL externalUrl) { - } -} diff --git a/src/main/java/run/halo/feed/FeedSourceFinder.java b/src/main/java/run/halo/feed/FeedSourceFinder.java deleted file mode 100644 index 78a29bb..0000000 --- a/src/main/java/run/halo/feed/FeedSourceFinder.java +++ /dev/null @@ -1,65 +0,0 @@ -package run.halo.feed; - -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; -import run.halo.app.core.extension.User; -import run.halo.app.core.extension.content.Category; -import run.halo.app.core.extension.content.Post; -import run.halo.app.extension.ListResult; - -public interface FeedSourceFinder { - - /** - * List all posts. - * - * @param page page number - * @param size page size - * @return post list result - */ - Mono> listPosts(Integer page, Integer size); - - /** - * List posts by category. - * - * @param page page number - * @param size page size - * @param category category metadata name - * @return post list result - */ - Mono> listPostsByCategory(Integer page, Integer size, String category); - - /** - * List posts by author. - * - * @param page page number - * @param size page size - * @param author author slug name - * @return post list result - */ - Mono> listPostsByAuthor(Integer page, Integer size, String author); - - /** - * Get post snapshot post content. - * - * @param snapshotName snapshot name - * @param baseSnapshotName base snapshot name - * @return post content - */ - Mono getPostContent(String snapshotName, String baseSnapshotName); - - /** - * Get categories by category slug. - * - * @param slug category slug - * @return category - */ - Flux getCategoriesContentBySlug(String slug); - - /** - * Get user by user metadata name. - * - * @param name user metadata name - * @return user - */ - Mono getUserByName(String name); -} diff --git a/src/main/java/run/halo/feed/FeedSourceFinderImpl.java b/src/main/java/run/halo/feed/FeedSourceFinderImpl.java deleted file mode 100644 index 6df427f..0000000 --- a/src/main/java/run/halo/feed/FeedSourceFinderImpl.java +++ /dev/null @@ -1,116 +0,0 @@ -package run.halo.feed; - -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.Predicate; -import lombok.AllArgsConstructor; -import org.apache.commons.lang3.StringUtils; -import org.springframework.stereotype.Component; -import org.springframework.util.Assert; -import org.springframework.util.comparator.Comparators; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; -import run.halo.app.core.extension.User; -import run.halo.app.core.extension.content.Category; -import run.halo.app.core.extension.content.Post; -import run.halo.app.core.extension.content.Snapshot; -import run.halo.app.extension.ListResult; -import run.halo.app.extension.MetadataUtil; -import run.halo.app.extension.ReactiveExtensionClient; - -@Component -@AllArgsConstructor -public class FeedSourceFinderImpl implements FeedSourceFinder { - private final ReactiveExtensionClient client; - - public static final Predicate FIXED_PREDICATE = post -> post.isPublished() - && Objects.equals(false, - post.getSpec().getDeleted()) - && Post.VisibleEnum.PUBLIC.equals( - post.getSpec().getVisible()) - && post.getMetadata() - .getDeletionTimestamp() == - null; - - static Comparator defaultComparator() { - Function publishTime = - post -> post.getSpec().getPublishTime(); - Function name = post -> post.getMetadata().getName(); - return Comparator.comparing(publishTime, Comparators.nullsLow()) - .thenComparing(name) - .reversed(); - } - - @Override - public Mono> listPosts(Integer page, Integer size) { - return listPost(page, size, null, defaultComparator()); - } - - @Override - public Mono> listPostsByCategory(Integer page, Integer size, String category) { - return listPost(page, size, post -> contains(post.getSpec().getCategories(), category), - defaultComparator()); - } - - @Override - public Mono> listPostsByAuthor(Integer page, Integer size, String author) { - return listPost(page, size, post -> post.getSpec().getOwner().equals(author), - defaultComparator()); - } - - private boolean contains(List c, String key) { - if (StringUtils.isBlank(key) || c == null) { - return false; - } - return c.contains(key); - } - - private Mono> listPost(Integer page, Integer size, - Predicate postPredicate, - Comparator comparator) { - Predicate predicate = FIXED_PREDICATE - .and(postPredicate == null ? post -> true : postPredicate); - return client.list(Post.class, predicate, comparator, page, size); - } - - @Override - public Mono getPostContent(String snapshotName, String baseSnapshotName) { - return client.fetch(Snapshot.class, baseSnapshotName) - .doOnNext(this::checkBaseSnapshot) - .flatMap(baseSnapshot -> { - if (StringUtils.equals(snapshotName, baseSnapshotName)) { - var wrapper = ContentWrapper.patchSnapshot(baseSnapshot, baseSnapshot); - return Mono.just(wrapper); - } - return client.fetch(Snapshot.class, snapshotName) - .map(snapshot -> ContentWrapper.patchSnapshot(snapshot, baseSnapshot)); - }); - } - - private void checkBaseSnapshot(Snapshot snapshot) { - Assert.notNull(snapshot, "The snapshot must not be null."); - String keepRawAnno = - MetadataUtil.nullSafeAnnotations(snapshot).get(Snapshot.KEEP_RAW_ANNO); - if (!StringUtils.equals(Boolean.TRUE.toString(), keepRawAnno)) { - throw new IllegalArgumentException( - String.format("The snapshot [%s] is not a base snapshot.", - snapshot.getMetadata().getName())); - } - } - - @Override - public Flux getCategoriesContentBySlug(String slug) { - return client.list(Category.class, - category -> StringUtils.equals(category.getSpec().getSlug(), slug), - Comparator.naturalOrder()); - } - - @Override - public Mono getUserByName(String name) { - return client.fetch(User.class, name); - } - -} diff --git a/src/main/java/run/halo/feed/PatchUtils.java b/src/main/java/run/halo/feed/PatchUtils.java deleted file mode 100644 index 82c4ccf..0000000 --- a/src/main/java/run/halo/feed/PatchUtils.java +++ /dev/null @@ -1,87 +0,0 @@ -package run.halo.feed; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.github.difflib.DiffUtils; -import com.github.difflib.patch.AbstractDelta; -import com.github.difflib.patch.ChangeDelta; -import com.github.difflib.patch.Chunk; -import com.github.difflib.patch.DeleteDelta; -import com.github.difflib.patch.DeltaType; -import com.github.difflib.patch.InsertDelta; -import com.github.difflib.patch.Patch; -import com.github.difflib.patch.PatchFailedException; -import com.google.common.base.Splitter; -import java.util.Collections; -import java.util.List; -import lombok.Data; -import org.apache.commons.lang3.StringUtils; -import run.halo.app.infra.utils.JsonUtils; - -/** - * Copy from here. - */ -public class PatchUtils { - private static final String DELIMITER = "\n"; - private static final Splitter lineSplitter = Splitter.on(DELIMITER); - - public static Patch create(String deltasJson) { - List deltas = JsonUtils.jsonToObject(deltasJson, new TypeReference<>() { - }); - Patch patch = new Patch<>(); - for (Delta delta : deltas) { - StringChunk sourceChunk = delta.getSource(); - StringChunk targetChunk = delta.getTarget(); - Chunk orgChunk = new Chunk<>(sourceChunk.getPosition(), sourceChunk.getLines(), - sourceChunk.getChangePosition()); - Chunk revChunk = new Chunk<>(targetChunk.getPosition(), targetChunk.getLines(), - targetChunk.getChangePosition()); - switch (delta.getType()) { - case DELETE -> patch.addDelta(new DeleteDelta<>(orgChunk, revChunk)); - case INSERT -> patch.addDelta(new InsertDelta<>(orgChunk, revChunk)); - case CHANGE -> patch.addDelta(new ChangeDelta<>(orgChunk, revChunk)); - default -> throw new IllegalArgumentException("Unsupported delta type."); - } - } - return patch; - } - - public static String patchToJson(Patch patch) { - List> deltas = patch.getDeltas(); - return JsonUtils.objectToJson(deltas); - } - - public static String applyPatch(String original, String patchJson) { - Patch patch = PatchUtils.create(patchJson); - try { - return String.join(DELIMITER, patch.applyTo(breakLine(original))); - } catch (PatchFailedException e) { - throw new RuntimeException(e); - } - } - - public static String diffToJsonPatch(String original, String revised) { - Patch patch = DiffUtils.diff(breakLine(original), breakLine(revised)); - return PatchUtils.patchToJson(patch); - } - - public static List breakLine(String content) { - if (StringUtils.isBlank(content)) { - return Collections.emptyList(); - } - return lineSplitter.splitToList(content); - } - - @Data - public static class Delta { - private StringChunk source; - private StringChunk target; - private DeltaType type; - } - - @Data - public static class StringChunk { - private int position; - private List lines; - private List changePosition; - } -} diff --git a/src/main/java/run/halo/feed/RSS2.java b/src/main/java/run/halo/feed/RSS2.java deleted file mode 100644 index df694c0..0000000 --- a/src/main/java/run/halo/feed/RSS2.java +++ /dev/null @@ -1,67 +0,0 @@ -package run.halo.feed; - -import lombok.Builder; -import lombok.Data; -import org.dom4j.Document; -import org.dom4j.DocumentHelper; -import org.dom4j.Element; - -import java.time.Instant; -import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; -import java.util.List; - -@Data -@Builder -public class RSS2 { - private String title; - - private String link; - - private String description; - - private List items; - - @Data - @Builder - public static class Item { - private String title; - - private String link; - - private String description; - - private Instant pubDate; - - private String guid; - } - - public String toXmlString() { - Document document = DocumentHelper.createDocument(); - - Element root = DocumentHelper.createElement("rss"); - root.addAttribute("version", "2.0"); - document.setRootElement(root); - - Element channel = root.addElement("channel"); - channel.addElement("title").addText(title); - channel.addElement("link").addText(link); - channel.addElement("description").addText(description); - - // TODO lastBuildDate need upgrade halo dependency version - - if (items != null) { - items.forEach(item -> { - Element itemElement = channel.addElement("item"); - itemElement.addElement("title").addCDATA(item.getTitle()); - itemElement.addElement("link").addText(item.getLink()); - itemElement.addElement("description").addCDATA(item.getDescription()); - itemElement.addElement("guid").addText(item.getGuid()); - itemElement.addElement("pubDate") - .addText(item.pubDate.atOffset(ZoneOffset.UTC).format(DateTimeFormatter.RFC_1123_DATE_TIME)); - }); - } - - return document.asXML(); - } -} diff --git a/src/main/java/run/halo/feed/ReactiveSettingFetcher.java b/src/main/java/run/halo/feed/ReactiveSettingFetcher.java deleted file mode 100644 index 8ca0128..0000000 --- a/src/main/java/run/halo/feed/ReactiveSettingFetcher.java +++ /dev/null @@ -1,37 +0,0 @@ -package run.halo.feed; - -import lombok.AllArgsConstructor; -import org.springframework.lang.NonNull; -import org.springframework.stereotype.Component; -import reactor.core.publisher.Mono; -import run.halo.app.extension.ConfigMap; -import run.halo.app.extension.ReactiveExtensionClient; -import run.halo.app.infra.utils.JsonUtils; - -import java.util.Map; - -@Component -@AllArgsConstructor -public class ReactiveSettingFetcher { - private final ReactiveExtensionClient extensionClient; - - public Mono fetch(String configMapName, String group, Class clazz) { - return getValuesInternal(configMapName) - .filter(map -> map.containsKey(group)) - .map(map -> map.get(group)) - .mapNotNull(stringValue -> JsonUtils.jsonToObject(stringValue, clazz)); - } - - - @NonNull - private Mono> getValuesInternal(String configMapName) { - return getConfigMap(configMapName) - .filter(configMap -> configMap.getData() != null) - .map(ConfigMap::getData) - .defaultIfEmpty(Map.of()); - } - - private Mono getConfigMap(String configMapName) { - return extensionClient.fetch(ConfigMap.class, configMapName); - } -} diff --git a/src/test/java/run/halo/feed/RSS2Test.java b/src/test/java/run/halo/feed/RSS2Test.java deleted file mode 100644 index 5990cbb..0000000 --- a/src/test/java/run/halo/feed/RSS2Test.java +++ /dev/null @@ -1,54 +0,0 @@ -package run.halo.feed; - -import org.junit.jupiter.api.Test; - -import java.time.Instant; -import java.util.Arrays; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -class RSS2Test { - - @Test - void toXmlString() { - String result = RSS2.builder() - .title("title") - .description("description") - .link("link") - .items(Arrays.asList( - RSS2.Item.builder() - .title("title1") - .description("description1") - .link("link1") - .pubDate(Instant.EPOCH) - .guid("guid1") - .build(), - RSS2.Item.builder() - .title("title2") - .description("description2") - .link("link2") - .pubDate(Instant.ofEpochSecond(2208988800L)) - .guid("guid2") - .build() - )) - .build() - .toXmlString(); - - String standard = "\n" + - "" + - "" + - "title" + - "link" + - "description" + - "<![CDATA[title1]]>link1" + - "guid1" + - "Thu, 1 Jan 1970 00:00:00 GMT" + - "<![CDATA[title2]]>link2" + - "guid2" + - "Sun, 1 Jan 2040 00:00:00 GMT" + - "" + - ""; - - assertEquals(standard, result); - } -} \ No newline at end of file