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/config/SecurityConfig.java b/backend/src/main/java/com/openisle/config/SecurityConfig.java index 100c0a2e3..9fabbacbd 100644 --- a/backend/src/main/java/com/openisle/config/SecurityConfig.java +++ b/backend/src/main/java/com/openisle/config/SecurityConfig.java @@ -119,6 +119,7 @@ public class SecurityConfig { .requestMatchers(HttpMethod.GET, "/api/reaction-types").permitAll() .requestMatchers(HttpMethod.GET, "/api/activities/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/sitemap.xml").permitAll() + .requestMatchers(HttpMethod.GET, "/api/rss").permitAll() .requestMatchers(HttpMethod.GET, "/api/point-goods").permitAll() .requestMatchers(HttpMethod.POST, "/api/point-goods").permitAll() .requestMatchers(HttpMethod.POST, "/api/categories/**").hasAuthority("ADMIN") @@ -154,7 +155,8 @@ public class SecurityConfig { uri.startsWith("/api/reaction-types") || uri.startsWith("/api/config") || uri.startsWith("/api/activities") || uri.startsWith("/api/push/public-key") || uri.startsWith("/api/point-goods") || - uri.startsWith("/api/sitemap.xml") || uri.startsWith("/api/medals")); + uri.startsWith("/api/sitemap.xml") || uri.startsWith("/api/medals") || + uri.startsWith("/api/rss")); if (authHeader != null && authHeader.startsWith("Bearer ")) { String token = authHeader.substring(7); diff --git a/backend/src/main/java/com/openisle/controller/AdminPostController.java b/backend/src/main/java/com/openisle/controller/AdminPostController.java index 8e17d1c3e..3c9414cad 100644 --- a/backend/src/main/java/com/openisle/controller/AdminPostController.java +++ b/backend/src/main/java/com/openisle/controller/AdminPostController.java @@ -45,4 +45,14 @@ public class AdminPostController { public PostSummaryDto unpin(@PathVariable Long id) { return postMapper.toSummaryDto(postService.unpinPost(id)); } + + @PostMapping("/{id}/rss-exclude") + public PostSummaryDto excludeFromRss(@PathVariable Long id) { + return postMapper.toSummaryDto(postService.excludeFromRss(id)); + } + + @PostMapping("/{id}/rss-include") + public PostSummaryDto includeInRss(@PathVariable Long id) { + return postMapper.toSummaryDto(postService.includeInRss(id)); + } } diff --git a/backend/src/main/java/com/openisle/controller/RssController.java b/backend/src/main/java/com/openisle/controller/RssController.java new file mode 100644 index 000000000..4970ebcc1 --- /dev/null +++ b/backend/src/main/java/com/openisle/controller/RssController.java @@ -0,0 +1,282 @@ +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.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@RestController +@RequiredArgsConstructor +public class RssController { + private final PostService postService; + + @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); + String base = trimTrailingSlash(websiteUrl); + + StringBuilder sb = new StringBuilder(4096); + sb.append(""); + sb.append(""); + sb.append(""); + 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 = 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(""); + elem(sb, "title", cdata(nullSafe(p.getTitle()))); + elem(sb, "link", link); + sb.append("").append(link).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); + m = HTML_IMAGE.matcher(content); + 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 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/backend/src/main/java/com/openisle/dto/PostSummaryDto.java b/backend/src/main/java/com/openisle/dto/PostSummaryDto.java index d849c225b..e47a03e11 100644 --- a/backend/src/main/java/com/openisle/dto/PostSummaryDto.java +++ b/backend/src/main/java/com/openisle/dto/PostSummaryDto.java @@ -31,5 +31,6 @@ public class PostSummaryDto { private int pointReward; private PostType type; private LotteryDto lottery; + private boolean rssExcluded; } diff --git a/backend/src/main/java/com/openisle/mapper/PostMapper.java b/backend/src/main/java/com/openisle/mapper/PostMapper.java index 58652f17b..a80da01f7 100644 --- a/backend/src/main/java/com/openisle/mapper/PostMapper.java +++ b/backend/src/main/java/com/openisle/mapper/PostMapper.java @@ -63,6 +63,7 @@ public class PostMapper { dto.setCommentCount(commentService.countComments(post.getId())); dto.setStatus(post.getStatus()); dto.setPinnedAt(post.getPinnedAt()); + dto.setRssExcluded(post.isRssExcluded()); List reactions = reactionService.getReactionsForPost(post.getId()) .stream() diff --git a/backend/src/main/java/com/openisle/model/Post.java b/backend/src/main/java/com/openisle/model/Post.java index 0f5a709f1..cbbc75a0e 100644 --- a/backend/src/main/java/com/openisle/model/Post.java +++ b/backend/src/main/java/com/openisle/model/Post.java @@ -67,4 +67,7 @@ public class Post { @Column private LocalDateTime pinnedAt; + @Column(nullable = false) + private boolean rssExcluded = false; + } diff --git a/backend/src/main/java/com/openisle/repository/PostRepository.java b/backend/src/main/java/com/openisle/repository/PostRepository.java index d764bb1dd..58083b193 100644 --- a/backend/src/main/java/com/openisle/repository/PostRepository.java +++ b/backend/src/main/java/com/openisle/repository/PostRepository.java @@ -106,4 +106,6 @@ public interface PostRepository extends JpaRepository { "WHERE p.createdAt >= :start AND p.createdAt < :end GROUP BY d ORDER BY d") java.util.List countDailyRange(@Param("start") LocalDateTime start, @Param("end") LocalDateTime end); + + List findByStatusAndRssExcludedFalseOrderByCreatedAtDesc(PostStatus status, Pageable pageable); } diff --git a/backend/src/main/java/com/openisle/service/PostService.java b/backend/src/main/java/com/openisle/service/PostService.java index 30a785ce5..2e1106e04 100644 --- a/backend/src/main/java/com/openisle/service/PostService.java +++ b/backend/src/main/java/com/openisle/service/PostService.java @@ -132,6 +132,23 @@ public class PostService { this.publishMode = publishMode; } + public List listLatestRssPosts(int limit) { + Pageable pageable = PageRequest.of(0, limit, Sort.by(Sort.Direction.DESC, "createdAt")); + return postRepository.findByStatusAndRssExcludedFalseOrderByCreatedAtDesc(PostStatus.PUBLISHED, pageable); + } + + public Post excludeFromRss(Long id) { + Post post = postRepository.findById(id).orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + post.setRssExcluded(true); + return postRepository.save(post); + } + + public Post includeInRss(Long id) { + Post post = postRepository.findById(id).orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + post.setRssExcluded(false); + return postRepository.save(post); + } + public Post createPost(String username, Long categoryId, String title, diff --git a/backend/src/main/resources/db/migration/V2__add_rss_excluded_to_posts.sql b/backend/src/main/resources/db/migration/V2__add_rss_excluded_to_posts.sql new file mode 100644 index 000000000..e8183ccec --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__add_rss_excluded_to_posts.sql @@ -0,0 +1 @@ +ALTER TABLE posts ADD COLUMN rss_excluded BOOLEAN NOT NULL DEFAULT 0; diff --git a/frontend_nuxt/components/HeaderComponent.vue b/frontend_nuxt/components/HeaderComponent.vue index 5aec0bb28..524e48178 100644 --- a/frontend_nuxt/components/HeaderComponent.vue +++ b/frontend_nuxt/components/HeaderComponent.vue @@ -29,6 +29,18 @@ +
+ + 邀请 + +
+ + +
+ +
+
+
@@ -66,6 +78,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 +99,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 +118,41 @@ 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 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 }) @@ -224,7 +277,7 @@ onMounted(async () => { margin-left: auto; flex-direction: row; align-items: center; - gap: 20px; + gap: 30px; } .auth-btns { @@ -315,11 +368,26 @@ onMounted(async () => { cursor: pointer; } +.invite_text { + font-size: 12px; + cursor: pointer; + color: var(--primary-color); +} + +.invite_text:hover { + 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; diff --git a/frontend_nuxt/pages/posts/[id]/index.vue b/frontend_nuxt/pages/posts/[id]/index.vue index b87a251e5..55f69a1a0 100644 --- a/frontend_nuxt/pages/posts/[id]/index.vue +++ b/frontend_nuxt/pages/posts/[id]/index.vue @@ -268,6 +268,7 @@ const postReactions = ref([]) const comments = ref([]) const status = ref('PUBLISHED') const pinnedAt = ref(null) +const rssExcluded = ref(false) const isWaitingPostingComment = ref(false) const postTime = ref('') const postItems = ref([]) @@ -356,6 +357,11 @@ const articleMenuItems = computed(() => { } else { items.push({ text: '置顶', onClick: () => pinPost() }) } + if (rssExcluded.value) { + items.push({ text: '取消rss不推荐', onClick: () => includeRss() }) + } else { + items.push({ text: 'rss不推荐', onClick: () => excludeRss() }) + } } if (isAdmin.value && status.value === 'PENDING') { items.push({ text: '通过审核', onClick: () => approvePost() }) @@ -480,6 +486,7 @@ watchEffect(() => { subscribed.value = !!data.subscribed status.value = data.status pinnedAt.value = data.pinnedAt + rssExcluded.value = data.rssExcluded postTime.value = TimeManager.format(data.createdAt) lottery.value = data.lottery || null if (lottery.value && lottery.value.endTime) startCountdown() @@ -645,6 +652,36 @@ const unpinPost = async () => { } } +const excludeRss = async () => { + const token = getToken() + if (!token) return + const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/rss-exclude`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }) + if (res.ok) { + rssExcluded.value = true + toast.success('已标记为rss不推荐') + } else { + toast.error('操作失败') + } +} + +const includeRss = async () => { + const token = getToken() + if (!token) return + const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/rss-include`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }) + if (res.ok) { + rssExcluded.value = false + toast.success('已取消rss不推荐') + } else { + toast.error('操作失败') + } +} + const editPost = () => { navigateTo(`/posts/${postId}/edit`, { replace: true }) }