From 1b31977ec66df397b70862a4bb825f4ad846d3fe Mon Sep 17 00:00:00 2001 From: tim Date: Mon, 18 Aug 2025 19:43:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20rss=E7=BB=86=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/pom.xml | 10 + .../openisle/controller/RssController.java | 262 ++++++++++++++++-- frontend_nuxt/components/HeaderComponent.vue | 17 ++ 3 files changed, 260 insertions(+), 29 deletions(-) diff --git a/backend/pom.xml b/backend/pom.xml index 445fa4058..56c4048eb 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -38,6 +38,16 @@ org.springframework.boot spring-boot-starter-security + + com.vladsch.flexmark + flexmark-all + 0.64.8 + + + org.jsoup + jsoup + 1.17.2 + com.mysql mysql-connector-j diff --git a/backend/src/main/java/com/openisle/controller/RssController.java b/backend/src/main/java/com/openisle/controller/RssController.java index f0a3b98d2..4970ebcc1 100644 --- a/backend/src/main/java/com/openisle/controller/RssController.java +++ b/backend/src/main/java/com/openisle/controller/RssController.java @@ -3,13 +3,27 @@ package com.openisle.controller; import com.openisle.model.Post; import com.openisle.service.PostService; import lombok.RequiredArgsConstructor; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.safety.Safelist; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; +import com.vladsch.flexmark.ext.autolink.AutolinkExtension; +import com.vladsch.flexmark.ext.tables.TablesExtension; +import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension; +import com.vladsch.flexmark.ext.gfm.tasklist.TaskListExtension; +import com.vladsch.flexmark.html.HtmlRenderer; +import com.vladsch.flexmark.parser.Parser; +import com.vladsch.flexmark.util.data.MutableDataSet; + +import java.net.URI; import java.time.ZoneId; +import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; -import java.util.List; +import java.util.*; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -21,58 +35,248 @@ public class RssController { @Value("${app.website-url:https://www.open-isle.com}") private String websiteUrl; + // 兼容 Markdown/HTML 两类图片写法(用于 enclosure) private static final Pattern MD_IMAGE = Pattern.compile("!\\[[^\\]]*\\]\\(([^)]+)\\)"); private static final Pattern HTML_IMAGE = Pattern.compile("]+src=[\"']?([^\"'>]+)[\"']?[^>]*>"); + private static final DateTimeFormatter RFC1123 = DateTimeFormatter.RFC_1123_DATE_TIME; + + // flexmark:Markdown -> HTML + private static final Parser MD_PARSER; + private static final HtmlRenderer MD_RENDERER; + static { + MutableDataSet opts = new MutableDataSet(); + opts.set(Parser.EXTENSIONS, Arrays.asList( + TablesExtension.create(), + AutolinkExtension.create(), + StrikethroughExtension.create(), + TaskListExtension.create() + )); + // 允许内联 HTML(下游再做 sanitize) + opts.set(Parser.HTML_BLOCK_PARSER, true); + MD_PARSER = Parser.builder(opts).build(); + MD_RENDERER = HtmlRenderer.builder(opts).escapeHtml(false).build(); + } + @GetMapping(value = "/api/rss", produces = "application/rss+xml;charset=UTF-8") public String feed() { + // 建议 20;你现在是 10,这里保留你的 10 List posts = postService.listLatestRssPosts(10); - StringBuilder sb = new StringBuilder(); + String base = trimTrailingSlash(websiteUrl); + + StringBuilder sb = new StringBuilder(4096); sb.append(""); - sb.append(""); + sb.append(""); sb.append(""); - sb.append("OpenIsle RSS"); - sb.append("").append(websiteUrl).append(""); - sb.append("Latest posts"); - DateTimeFormatter fmt = DateTimeFormatter.RFC_1123_DATE_TIME; + elem(sb, "title", cdata("OpenIsle RSS")); + elem(sb, "link", base + "/"); + elem(sb, "description", cdata("Latest posts")); + ZonedDateTime updated = posts.stream() + .map(p -> p.getCreatedAt().atZone(ZoneId.systemDefault())) + .max(Comparator.naturalOrder()) + .orElse(ZonedDateTime.now()); + // channel lastBuildDate(GMT) + elem(sb, "lastBuildDate", toRfc1123Gmt(updated)); + for (Post p : posts) { - String link = websiteUrl + "/posts/" + p.getId(); + String link = base + "/posts/" + p.getId(); + + // 1) Markdown -> HTML + String html = renderMarkdown(p.getContent()); + + // 2) Sanitize(白名单增强) + String safeHtml = sanitizeHtml(html); + + // 3) 绝对化 href/src + 强制 rel/target + String absHtml = absolutifyHtml(safeHtml, base); + + // 4) 纯文本摘要(用于 ) + String plain = textSummary(absHtml, 180); + + // 5) enclosure(首图,已绝对化) + String enclosure = firstImage(p.getContent()); + if (enclosure == null) { + // 如果 Markdown 没有图,尝试从渲染后的 HTML 再抓一次 + enclosure = firstImage(absHtml); + } + if (enclosure != null) { + enclosure = absolutifyUrl(enclosure, base); + } + sb.append(""); - sb.append("<![CDATA[").append(p.getTitle()).append("]]>"); - sb.append("").append(link).append(""); + elem(sb, "title", cdata(nullSafe(p.getTitle()))); + elem(sb, "link", link); sb.append("").append(link).append(""); - sb.append("") - .append(p.getCreatedAt().atZone(ZoneId.systemDefault()).format(fmt)) - .append(""); - String desc = p.getContent() + "\n

更多细节请访问原文:" + link + "

"; - sb.append(""); - String img = firstImage(p.getContent()); - if (img != null) { - sb.append(""); + elem(sb, "pubDate", toRfc1123Gmt(p.getCreatedAt().atZone(ZoneId.systemDefault()))); + // 摘要 + elem(sb, "description", cdata(plain)); + // 全文(HTML) + sb.append(""); + // 首图 enclosure(图片类型) + if (enclosure != null) { + sb.append(""); } sb.append("
"); } + sb.append("
"); return sb.toString(); } + /* ===================== Markdown → HTML ===================== */ + + private static String renderMarkdown(String md) { + if (md == null || md.isEmpty()) return ""; + return MD_RENDERER.render(MD_PARSER.parse(md)); + } + + /* ===================== Sanitize & 绝对化 ===================== */ + + private static String sanitizeHtml(String html) { + if (html == null) return ""; + Safelist wl = Safelist.relaxed() + .addTags("pre", "code", "figure", "figcaption", "picture", "source", + "table","thead","tbody","tr","th","td","h1","h2","h3","h4","h5","h6") + .addAttributes("a", "href", "title", "target", "rel") + .addAttributes("img", "src", "alt", "title", "width", "height") + .addAttributes("source", "srcset", "type", "media") + .addAttributes("code", "class") + .addAttributes("pre", "class") + .addProtocols("a", "href", "http", "https", "mailto") + .addProtocols("img", "src", "http", "https", "data") + .addProtocols("source", "srcset", "http", "https"); + // 清除所有 on* 事件、style(避免阅读器环境差异) + return Jsoup.clean(html, wl); + } + + private static String absolutifyHtml(String html, String baseUrl) { + if (html == null || html.isEmpty()) return ""; + Document doc = Jsoup.parseBodyFragment(html, baseUrl); + // a[href] + for (Element a : doc.select("a[href]")) { + String href = a.attr("href"); + String abs = absolutifyUrl(href, baseUrl); + a.attr("href", abs); + // 强制外链安全属性 + a.attr("rel", "noopener noreferrer nofollow"); + a.attr("target", "_blank"); + } + // img[src] + for (Element img : doc.select("img[src]")) { + String src = img.attr("src"); + String abs = absolutifyUrl(src, baseUrl); + img.attr("src", abs); + } + // source[srcset] (picture/webp) + for (Element s : doc.select("source[srcset]")) { + String srcset = s.attr("srcset"); + s.attr("srcset", absolutifySrcset(srcset, baseUrl)); + } + return doc.body().html(); + } + + private static String absolutifyUrl(String url, String baseUrl) { + if (url == null || url.isEmpty()) return url; + String u = url.trim(); + if (u.startsWith("//")) { + return "https:" + u; + } + if (u.startsWith("#")) { + // 保留页面内锚点:拼接到首页(也可拼接到当前帖子的 link,但此处无上下文) + return baseUrl + "/" + u; + } + try { + URI base = URI.create(ensureTrailingSlash(baseUrl)); + URI abs = base.resolve(u); + return abs.toString(); + } catch (Exception e) { + return url; + } + } + + private static String absolutifySrcset(String srcset, String baseUrl) { + if (srcset == null || srcset.isEmpty()) return srcset; + String[] parts = srcset.split(","); + List out = new ArrayList<>(parts.length); + for (String part : parts) { + String p = part.trim(); + if (p.isEmpty()) continue; + String[] seg = p.split("\\s+"); + String url = seg[0]; + String size = seg.length > 1 ? seg[1] : ""; + out.add(absolutifyUrl(url, baseUrl) + (size.isEmpty() ? "" : " " + size)); + } + return String.join(", ", out); + } + + /* ===================== 摘要 & enclosure ===================== */ + + private static String textSummary(String html, int maxLen) { + if (html == null) return ""; + String text = Jsoup.parse(html).text().replaceAll("\\s+", " ").trim(); + if (text.length() <= maxLen) return text; + return text.substring(0, maxLen) + "…"; + } + private String firstImage(String content) { + if (content == null) return null; Matcher m = MD_IMAGE.matcher(content); - if (m.find()) { - return m.group(1); - } + if (m.find()) return m.group(1); m = HTML_IMAGE.matcher(content); - if (m.find()) { - return m.group(1); - } + if (m.find()) return m.group(1); + // 再从纯 HTML 里解析一次(如果传入的是渲染后的) + try { + Document doc = Jsoup.parse(content); + Element img = doc.selectFirst("img[src]"); + if (img != null) return img.attr("src"); + } catch (Exception ignored) {} return null; } - private String getMimeType(String url) { - String lower = url.toLowerCase(); - if (lower.endsWith(".png")) return "image/png"; - if (lower.endsWith(".gif")) return "image/gif"; + private static String getMimeType(String url) { + String lower = url == null ? "" : url.toLowerCase(Locale.ROOT); + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".gif")) return "image/gif"; + if (lower.endsWith(".webp")) return "image/webp"; + if (lower.endsWith(".svg")) return "image/svg+xml"; + if (lower.endsWith(".avif")) return "image/avif"; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; + // 默认兜底 return "image/jpeg"; } + + /* ===================== 时间/字符串/XML ===================== */ + + private static String toRfc1123Gmt(ZonedDateTime zdt) { + return zdt.withZoneSameInstant(ZoneId.of("GMT")).format(RFC1123); + } + + private static String cdata(String s) { + if (s == null) return ""; + // 防止出现 "]]>" 终止标记破坏 CDATA + return "", "]]]]>") + "]]>"; + } + + private static void elem(StringBuilder sb, String name, String value) { + sb.append('<').append(name).append('>').append(value).append("'); + } + + private static String escapeXml(String s) { + if (s == null) return ""; + return s.replace("&", "&").replace("<", "<").replace(">", ">") + .replace("\"", """).replace("'", "'"); + } + + private static String trimTrailingSlash(String s) { + if (s == null) return ""; + return s.endsWith("/") ? s.substring(0, s.length() - 1) : s; + } + + private static String ensureTrailingSlash(String s) { + if (s == null || s.isEmpty()) return "/"; + return s.endsWith("/") ? s : s + "/"; + } + + private static String nullSafe(String s) { return s == null ? "" : s; } } diff --git a/frontend_nuxt/components/HeaderComponent.vue b/frontend_nuxt/components/HeaderComponent.vue index 0d2d1d020..524e48178 100644 --- a/frontend_nuxt/components/HeaderComponent.vue +++ b/frontend_nuxt/components/HeaderComponent.vue @@ -35,6 +35,12 @@ + +
+ +
+
+
@@ -141,6 +147,12 @@ const copyInviteLink = async () => { } } +const copyRssLink = async () => { + const rssLink = `${API_BASE_URL}/api/rss` + await navigator.clipboard.writeText(rssLink) + toast.success('RSS链接已复制') +} + const goToProfile = async () => { if (!authState.loggedIn) { navigateTo('/login', { replace: true }) @@ -366,11 +378,16 @@ onMounted(async () => { text-decoration: underline; } +.rss-icon, .new-post-icon { font-size: 18px; cursor: pointer; } +.rss-icon { + text-shadow: 0 0 10px var(--primary-color); +} + @media (max-width: 1200px) { .header-content { padding-left: 15px;