@@ -66,6 +72,11 @@ import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
import { fetchUnreadCount, notificationState } from '~/utils/notification'
import { useIsMobile } from '~/utils/screen'
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
+import { toast } from '~/main'
+import { getToken } from '~/utils/auth'
+const config = useRuntimeConfig()
+const API_BASE_URL = config.public.apiBaseUrl
+const WEBSITE_BASE_URL = config.public.websiteBaseUrl
const props = defineProps({
showMenuBtn: {
@@ -82,6 +93,7 @@ const showSearch = ref(false)
const searchDropdown = ref(null)
const userMenu = ref(null)
const menuBtn = ref(null)
+const isCopying = ref(false)
const search = () => {
showSearch.value = true
@@ -100,6 +112,35 @@ const goToLogin = () => {
const goToSettings = () => {
navigateTo('/settings', { replace: true })
}
+
+const copyInviteLink = async () => {
+ isCopying.value = true
+ const token = getToken()
+ if (!token) {
+ toast.error('请先登录')
+ return
+ }
+ try {
+ const res = await fetch(`${API_BASE_URL}/api/invite/generate`, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${token}` },
+ })
+ if (res.ok) {
+ const data = await res.json()
+ const inviteLink = data.token ? `${WEBSITE_BASE_URL}/signup?invite_token=${data.token}` : ''
+ await navigator.clipboard.writeText(inviteLink)
+ toast.success('邀请链接已复制')
+ } else {
+ const data = await res.json().catch(() => ({}))
+ toast.error(data.error || '生成邀请链接失败')
+ }
+ } catch (e) {
+ toast.error('生成邀请链接失败')
+ } finally {
+ isCopying.value = false
+ }
+}
+
const goToProfile = async () => {
if (!authState.loggedIn) {
navigateTo('/login', { replace: true })
@@ -224,7 +265,7 @@ onMounted(async () => {
margin-left: auto;
flex-direction: row;
align-items: center;
- gap: 20px;
+ gap: 30px;
}
.auth-btns {
@@ -315,6 +356,16 @@ onMounted(async () => {
cursor: pointer;
}
+.invite_text {
+ font-size: 12px;
+ cursor: pointer;
+ color: var(--primary-color);
+}
+
+.invite_text:hover {
+ text-decoration: underline;
+}
+
.new-post-icon {
font-size: 18px;
cursor: pointer;
From 1b31977ec66df397b70862a4bb825f4ad846d3fe Mon Sep 17 00:00:00 2001
From: tim
Date: Mon, 18 Aug 2025 19:43:34 +0800
Subject: [PATCH 3/3] =?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("");
- 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("").append(name).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;