refactor(server): remove RSS feed support (#6243)

This commit is contained in:
boojack 2026-08-28 23:06:25 +08:00 committed by GitHub
parent 171687c456
commit 5b6d8f87bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 5 additions and 778 deletions

View file

@ -95,7 +95,7 @@ Distribution is derived rather than separately configured:
- A comment or conversation query requires its context memo to be readable, then filters every replying memo by that memo's own audience.
- A `COMMENT` or `REFERENCE` relation and its snippet are returned only when both endpoints are readable.
- Reactions are readable whenever their memo is readable.
- RSS, public profiles, and other public surfaces continue to use `PUBLIC`, not Space placement.
- Public profiles and other public surfaces continue to use `PUBLIC`, not Space placement.
### Participation and governance

1
go.mod
View file

@ -15,7 +15,6 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/cel-go v0.31.0
github.com/google/jsonschema-go v0.4.3
github.com/gorilla/feeds v1.2.0
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0
github.com/johannesboyne/gofakes3 v1.2.0
github.com/joho/godotenv v1.5.1

2
go.sum
View file

@ -131,8 +131,6 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.21 h1:OFdQ3tnCX/zaQ0Cedu
github.com/googleapis/enterprise-certificate-proxy v0.3.21/go.mod h1:L3D/IQExI6LqEjBdXcZQ1WluSgigQmSwBboFstVPM4w=
github.com/googleapis/gax-go/v2 v2.23.0 h1:Tchl7qkvE7Ip3y+ztvNufYFvkfqTe7NfLTYGIdJRLuE=
github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg=
github.com/gorilla/feeds v1.2.0 h1:O6pBiXJ5JHhPvqy53NsjKOThq+dNFm8+DFrxBEdzSCc=
github.com/gorilla/feeds v1.2.0/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw=

View file

@ -10,7 +10,6 @@ import (
"github.com/yuin/goldmark/ast"
goldmarkextension "github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
@ -42,33 +41,6 @@ func (*gfmLinkify) Extend(markdown goldmark.Markdown) {
util.Prioritized(&gfmEmailASTTransformer{}, 950),
),
)
markdown.Renderer().AddOptions(
renderer.WithNodeRenderers(
util.Prioritized(&gfmEmailNodeRenderer{}, 500),
),
)
}
type gfmEmailNodeRenderer struct{}
func (*gfmEmailNodeRenderer) RegisterFuncs(registerer renderer.NodeRendererFuncRegisterer) {
registerer.Register(mast.KindGFMEmail, renderGFMEmailNode)
}
func renderGFMEmailNode(writer util.BufWriter, _ []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
emailNode, ok := node.(*mast.GFMEmailNode)
if !ok {
return ast.WalkContinue, nil
}
_, _ = writer.WriteString(`<a href="mailto:`)
_, _ = writer.Write(util.EscapeHTML(emailNode.Address))
_, _ = writer.WriteString(`">`)
_, _ = writer.Write(util.EscapeHTML(emailNode.Address))
_, _ = writer.WriteString(`</a>`)
return ast.WalkContinue, nil
}
func newGFMLinkifyParser() parser.InlineParser {

View file

@ -4,7 +4,6 @@ import (
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
@ -26,11 +25,6 @@ func (*mentionExtension) Extend(m goldmark.Markdown) {
util.Prioritized(&mentionASTTransformer{}, 1050),
),
)
m.Renderer().AddOptions(
renderer.WithNodeRenderers(
util.Prioritized(&mentionNodeRenderer{}, 500),
),
)
}
type mentionASTTransformer struct{}
@ -80,25 +74,3 @@ func replaceMentionsInText(textNode *ast.Text, source []byte) {
}
parent.RemoveChild(parent, textNode)
}
type mentionNodeRenderer struct{}
func (*mentionNodeRenderer) RegisterFuncs(registerer renderer.NodeRendererFuncRegisterer) {
registerer.Register(mast.KindMention, renderMentionNode)
}
func renderMentionNode(writer util.BufWriter, _ []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
mentionNode, ok := node.(*mast.MentionNode)
if !ok {
return ast.WalkContinue, nil
}
spelling := mentionNode.Source
if len(spelling) == 0 {
spelling = append([]byte{'@'}, mentionNode.Username...)
}
_, _ = writer.Write(util.EscapeHTML(spelling))
return ast.WalkContinue, nil
}

View file

@ -5,7 +5,6 @@ import (
"github.com/yuin/goldmark/ast"
east "github.com/yuin/goldmark/extension/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
@ -32,11 +31,6 @@ func (*tagExtension) Extend(m goldmark.Markdown) {
util.Prioritized(&tagASTTransformer{}, 1000),
),
)
m.Renderer().AddOptions(
renderer.WithNodeRenderers(
util.Prioritized(&tagNodeRenderer{}, 500),
),
)
}
type tagASTTransformer struct{}
@ -138,44 +132,3 @@ func insertSplitTextBefore(parent ast.Node, before ast.Node, original *ast.Text,
}
parent.InsertBefore(parent, before, newText)
}
type tagNodeRenderer struct{}
func (*tagNodeRenderer) RegisterFuncs(registerer renderer.NodeRendererFuncRegisterer) {
registerer.Register(mast.KindTag, renderTagNode)
registerer.Register(mast.KindInlineMath, renderMathNode)
registerer.Register(mast.KindBlockMath, renderMathNode)
}
func renderTagNode(writer util.BufWriter, _ []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
tagNode, ok := node.(*mast.TagNode)
if !ok {
return ast.WalkContinue, nil
}
spelling := tagNode.Source
if len(spelling) == 0 {
spelling = append([]byte{'#'}, tagNode.Tag...)
}
_, _ = writer.Write(util.EscapeHTML(spelling))
return ast.WalkContinue, nil
}
func renderMathNode(writer util.BufWriter, _ []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
var source []byte
switch mathNode := node.(type) {
case *mast.InlineMathNode:
source = mathNode.Source
case *mast.BlockMathNode:
source = mathNode.Source
default:
return ast.WalkContinue, nil
}
_, _ = writer.Write(util.EscapeHTML(source))
return ast.WalkContinue, nil
}

View file

@ -38,8 +38,6 @@ type ExtractedData struct {
// Service handles markdown metadata extraction.
// It uses goldmark to parse markdown and extract tags, properties, and snippets.
// HTML rendering is primarily done on frontend using markdown-it, but backend provides
// RenderHTML for RSS feeds and other server-side rendering needs.
type Service interface {
// ExtractAll extracts tags, properties, and references in a single parse (most efficient)
ExtractAll(content []byte) (*ExtractedData, error)
@ -53,9 +51,6 @@ type Service interface {
// RenderMarkdown renders goldmark AST back to markdown text
RenderMarkdown(content []byte) (string, error)
// RenderHTML renders markdown content to HTML
RenderHTML(content []byte) (string, error)
// GenerateSnippet creates plain text summary
GenerateSnippet(content []byte, maxLength int) (string, error)
@ -287,20 +282,6 @@ func (s *service) RenderMarkdown(content []byte) (string, error) {
return mdRenderer.Render(root, content), nil
}
// RenderHTML renders markdown content to HTML using goldmark's built-in HTML renderer.
func (s *service) RenderHTML(content []byte) (string, error) {
root, err := s.parse(content)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := s.md.Renderer().Render(&buf, content, root); err != nil {
return "", err
}
return buf.String(), nil
}
// GenerateSnippet creates a plain text summary from markdown content.
func (s *service) GenerateSnippet(content []byte, maxLength int) (string, error) {
root, err := s.parse(content)

View file

@ -486,10 +486,6 @@ func TestMentionSourceSpelling(t *testing.T) {
rendered, err := svc.RenderMarkdown([]byte(content))
require.NoError(t, err)
assert.Equal(t, content, rendered)
html, err := svc.RenderHTML([]byte(content))
require.NoError(t, err)
assert.Contains(t, html, "@Alice-2")
}
func TestExtractAllSkipsTagsInsideLinks(t *testing.T) {
@ -845,82 +841,7 @@ func TestTagSourceSpelling(t *testing.T) {
assert.Equal(t, "#done #\u0301foo", renamed)
}
func TestRenderHTMLPreservesTagSourceSpelling(t *testing.T) {
svc := NewService(WithTagExtension())
html, err := svc.RenderHTML([]byte("#R&D #A\u200dB"))
require.NoError(t, err)
assert.Equal(t, "<p>#R&amp;D #A\u200dB</p>\n", html)
}
func TestRenderHTMLRejectsUnclosedReferenceDestination(t *testing.T) {
svc := NewService(WithTagExtension())
html, err := svc.RenderHTML([]byte("[#use][bad]\n\n[bad]:("))
require.NoError(t, err)
assert.Equal(t, "<p>[#use][bad]</p>\n<p>[bad]:(</p>\n", html)
}
func TestRenderHTMLRecognizesGFMEmails(t *testing.T) {
svc := NewService(WithTagExtension())
tests := []struct {
content string
expected string
}{
{
content: "foo#mail@example.com",
expected: "<p>foo#<a href=\"mailto:mail@example.com\">mail@example.com</a></p>\n",
},
{
content: "#foo/bar_baz@example.com",
expected: "<p>#foo/<a href=\"mailto:bar_baz@example.com\">bar_baz@example.com</a></p>\n",
},
{
content: "_foo@example.com #tag_",
expected: "<p><em><a href=\"mailto:foo@example.com\">foo@example.com</a> #tag</em></p>\n",
},
{
content: "foo@bar.com@baz.example",
expected: "<p><a href=\"mailto:foo@bar.com\">foo@bar.com</a>@baz.example</p>\n",
},
{
content: "foo@bar.com+abc@def.com",
expected: "<p><a href=\"mailto:foo@bar.com\">foo@bar.com</a><a href=\"mailto:+abc@def.com\">+abc@def.com</a></p>\n",
},
{
content: `#foo\+bar@example.com`,
expected: "<p>#<a href=\"mailto:foo+bar@example.com\">foo+bar@example.com</a></p>\n",
},
{
content: `#foo\@example\.com`,
expected: "<p>#<a href=\"mailto:foo@example.com\">foo@example.com</a></p>\n",
},
{
content: `#foo&#46;bar@example.com`,
expected: "<p>#<a href=\"mailto:foo.bar@example.com\">foo.bar@example.com</a></p>\n",
},
{
content: `#foo&period;bar@example.com`,
expected: "<p>#<a href=\"mailto:foo.bar@example.com\">foo.bar@example.com</a></p>\n",
},
{
content: `#foo&#64;example.com`,
expected: "<p>#<a href=\"mailto:foo@example.com\">foo@example.com</a></p>\n",
},
}
for _, test := range tests {
html, err := svc.RenderHTML([]byte(test.content))
require.NoError(t, err)
assert.Equal(t, test.expected, html)
}
}
func TestRenderHTMLRecognizesGFMEmailWithoutTagExtension(t *testing.T) {
svc := NewService()
html, err := svc.RenderHTML([]byte("mail@example.com"))
require.NoError(t, err)
assert.Equal(t, "<p><a href=\"mailto:mail@example.com\">mail@example.com</a></p>\n", html)
}
func TestRenderHTMLRecognizesGFMURLsInsideUnresolvedBracketText(t *testing.T) {
func TestExtractAllRecognizesGFMURLsInsideUnresolvedBracketText(t *testing.T) {
svc := NewService(WithTagExtension())
for _, content := range []string{
"[ https://example.com/#hidden]",
@ -928,10 +849,6 @@ func TestRenderHTMLRecognizesGFMURLsInsideUnresolvedBracketText(t *testing.T) {
"![ https://example.com/#hidden]",
"[text https://example.com/#hidden",
} {
html, err := svc.RenderHTML([]byte(content))
require.NoError(t, err)
assert.Contains(t, html, "<a href=\"https://example.com/#hidden")
data, err := svc.ExtractAll([]byte(content))
require.NoError(t, err)
assert.Empty(t, data.Tags)
@ -968,10 +885,6 @@ func TestMathRenderingPreservesLiteralSource(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, content, rendered)
}
html, err := svc.RenderHTML([]byte("$x < y$\n\n$$meta\nx < y\n$$"))
require.NoError(t, err)
assert.Equal(t, "<p>$x &lt; y$</p>\n$$meta\nx &lt; y\n$$", html)
}
func TestRenderMarkdownPreservesLineBreakAfterTag(t *testing.T) {

View file

@ -80,7 +80,7 @@ func spaFallbackMiddleware(frontendFS fs.FS) echo.MiddlewareFunc {
}
func shouldSkipFrontendStatic(requestPath string) bool {
if requestPath == "/robots.txt" || requestPath == "/sitemap.xml" || strings.HasSuffix(requestPath, "/rss.xml") {
if requestPath == "/robots.txt" || requestPath == "/sitemap.xml" {
return true
}
return hasPathPrefix(requestPath, "/api") ||

View file

@ -15,7 +15,7 @@ source of truth and reuses the API's authentication and authorization as-is.
## Integration
`server.NewServer` calls `mcp.NewMCPService` after registering the API, file, RSS, and gRPC-gateway routes, passing the same Echo server:
`server.NewServer` calls `mcp.NewMCPService` after registering the API, file, and gRPC-gateway routes, passing the same Echo server:
```go
mcpService, err := mcp.NewMCPService(profile, echoServer)

View file

@ -1,365 +0,0 @@
package rss
import (
"context"
"crypto/sha256"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/gorilla/feeds"
"github.com/labstack/echo/v5"
"github.com/usememos/memos/internal/markdown"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store"
)
const (
maxRSSItemCount = 100
)
var (
// Regex to match markdown headings at the start of a line.
markdownHeadingRegex = regexp.MustCompile(`^#{1,6}\s*`)
)
type RSSService struct {
Store *store.Store
MarkdownService markdown.Service
}
type RSSHeading struct {
Title string
Description string
Language string
}
// NewRSSService creates an RSS service backed by the store and markdown renderer.
func NewRSSService(store *store.Store, markdownService markdown.Service) *RSSService {
return &RSSService{
Store: store,
MarkdownService: markdownService,
}
}
func (s *RSSService) RegisterRoutes(g *echo.Group) {
g.GET("/explore/rss.xml", s.GetExploreRSS)
g.GET("/u/:username/rss.xml", s.GetUserRSS)
}
func (s *RSSService) GetExploreRSS(c *echo.Context) error {
ctx := c.Request().Context()
allowAnonymous, err := s.Store.AllowsAnonymousAccess(ctx)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get instance access policy").Wrap(err)
}
if !allowAnonymous {
return echo.NewHTTPError(http.StatusNotFound, "RSS is unavailable")
}
normalStatus := store.Normal
limit := maxRSSItemCount
memoFind := store.FindMemo{
RowStatus: &normalStatus,
VisibilityList: []store.Visibility{store.Public},
Access: &store.MemoAccessScope{AllowPublic: true},
ExcludeComments: true,
Limit: &limit,
}
memoList, err := s.Store.ListMemos(ctx, &memoFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").Wrap(err)
}
baseURL := c.Scheme() + "://" + c.Request().Host
rss, lastModified, err := s.generateRSSFromMemoList(ctx, memoList, baseURL, nil)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate rss").Wrap(err)
}
etag := rssETag(rss)
s.setRSSHeaders(c, etag, lastModified)
if c.Request().Header.Get("If-None-Match") == etag {
return c.NoContent(http.StatusNotModified)
}
return c.String(http.StatusOK, rss)
}
func (s *RSSService) GetUserRSS(c *echo.Context) error {
ctx := c.Request().Context()
allowAnonymous, err := s.Store.AllowsAnonymousAccess(ctx)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get instance access policy").Wrap(err)
}
if !allowAnonymous {
return echo.NewHTTPError(http.StatusNotFound, "RSS is unavailable")
}
username := c.Param("username")
user, err := s.Store.GetUser(ctx, &store.FindUser{
Username: &username,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").Wrap(err)
}
if user == nil {
return echo.NewHTTPError(http.StatusNotFound, "User not found")
}
normalStatus := store.Normal
limit := maxRSSItemCount
memoFind := store.FindMemo{
CreatorID: &user.ID,
RowStatus: &normalStatus,
VisibilityList: []store.Visibility{store.Public},
Access: &store.MemoAccessScope{AllowPublic: true},
ExcludeComments: true,
Limit: &limit,
}
memoList, err := s.Store.ListMemos(ctx, &memoFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").Wrap(err)
}
baseURL := c.Scheme() + "://" + c.Request().Host
rss, lastModified, err := s.generateRSSFromMemoList(ctx, memoList, baseURL, user)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate rss").Wrap(err)
}
etag := rssETag(rss)
s.setRSSHeaders(c, etag, lastModified)
if c.Request().Header.Get("If-None-Match") == etag {
return c.NoContent(http.StatusNotModified)
}
return c.String(http.StatusOK, rss)
}
func (s *RSSService) generateRSSFromMemoList(ctx context.Context, memoList []*store.Memo, baseURL string, user *store.User) (string, time.Time, error) {
rssHeading, err := getRSSHeading(ctx, s.Store)
if err != nil {
return "", time.Time{}, err
}
itemCountLimit := min(len(memoList), maxRSSItemCount)
// Derive channel timestamps only from feed data. Using the request time here
// changes an otherwise identical body and defeats ETag revalidation.
var lastModified time.Time
for _, memo := range memoList[:itemCountLimit] {
if updated := time.Unix(memo.UpdatedTs, 0); updated.After(lastModified) {
lastModified = updated
}
}
feed := &feeds.Feed{
Title: rssHeading.Title,
Link: &feeds.Link{Href: baseURL},
Description: rssHeading.Description,
Created: lastModified,
Updated: lastModified,
}
if itemCountLimit == 0 {
// Return empty feed if no memos
rss, err := feed.ToRss()
return rss, time.Time{}, err
}
// Batch load all attachments for all memos to avoid N+1 query problem
memoIDs := make([]int32, itemCountLimit)
for i := 0; i < itemCountLimit; i++ {
memoIDs[i] = memoList[i].ID
}
allAttachments, err := s.Store.ListAttachments(ctx, &store.FindAttachment{
MemoIDList: memoIDs,
})
if err != nil {
return "", lastModified, err
}
// Group attachments by memo ID for quick lookup
attachmentsByMemoID := make(map[int32][]*store.Attachment)
for _, attachment := range allAttachments {
if attachment.MemoID != nil {
attachmentsByMemoID[*attachment.MemoID] = append(attachmentsByMemoID[*attachment.MemoID], attachment)
}
}
// Batch load all memo creators
creatorMap := make(map[int32]*store.User)
if user != nil {
// Single user feed - reuse the user object
creatorMap[user.ID] = user
} else {
// Multi-user feed - batch load all unique creators
creatorIDList := []int32{}
creatorIDMap := make(map[int32]bool)
for _, memo := range memoList[:itemCountLimit] {
if !creatorIDMap[memo.CreatorID] {
creatorIDList = append(creatorIDList, memo.CreatorID)
creatorIDMap[memo.CreatorID] = true
}
}
// Batch load all users with a single query
users, err := s.Store.ListUsers(ctx, &store.FindUser{
IDList: creatorIDList,
})
if err != nil {
return "", lastModified, err
}
for _, creator := range users {
creatorMap[creator.ID] = creator
}
}
// Generate feed items
feed.Items = make([]*feeds.Item, itemCountLimit)
for i := 0; i < itemCountLimit; i++ {
memo := memoList[i]
// Generate item title from memo content
title := s.generateItemTitle(memo.Content)
// Render content as HTML
htmlContent, err := s.getRSSItemDescription(memo.Content)
if err != nil {
return "", lastModified, err
}
link := &feeds.Link{Href: baseURL + "/memos/" + memo.UID}
item := &feeds.Item{
Title: title,
Link: link,
Description: htmlContent, // Summary/excerpt
Content: htmlContent, // Full content in content:encoded
Created: time.Unix(memo.CreatedTs, 0),
Updated: time.Unix(memo.UpdatedTs, 0),
Id: link.Href,
}
// Add author information
if creator, ok := creatorMap[memo.CreatorID]; ok {
authorName := creator.Nickname
if authorName == "" {
authorName = creator.Username
}
item.Author = &feeds.Author{
Name: authorName,
Email: creator.Email,
}
}
// Note: gorilla/feeds doesn't support categories in RSS items
// Tags could be added to the description or content if needed
// Add first attachment as enclosure
if attachments, ok := attachmentsByMemoID[memo.ID]; ok && len(attachments) > 0 {
attachment := attachments[0]
enclosure := feeds.Enclosure{}
if attachment.StorageType == storepb.AttachmentStorageType_EXTERNAL {
enclosure.Url = attachment.Reference
} else {
enclosure.Url = fmt.Sprintf("%s/file/attachments/%s", baseURL, attachment.UID)
}
enclosure.Length = strconv.Itoa(int(attachment.Size))
enclosure.Type = attachment.Type
item.Enclosure = &enclosure
}
feed.Items[i] = item
}
rss, err := feed.ToRss()
if err != nil {
return "", lastModified, err
}
return rss, lastModified, nil
}
func (*RSSService) generateItemTitle(content string) string {
// Extract first line as title
lines := strings.Split(content, "\n")
title := strings.TrimSpace(lines[0])
// Remove markdown heading syntax using regex (handles # to ###### with optional spaces)
title = markdownHeadingRegex.ReplaceAllString(title, "")
title = strings.TrimSpace(title)
// Limit title length
const maxTitleLength = 100
if len(title) > maxTitleLength {
// Find last space before limit to avoid cutting words
cutoff := maxTitleLength
for i := min(maxTitleLength-1, len(title)-1); i > 0; i-- {
if title[i] == ' ' {
cutoff = i
break
}
}
if cutoff < maxTitleLength {
title = title[:cutoff] + "..."
} else {
// No space found, just truncate
title = title[:maxTitleLength] + "..."
}
}
// If title is empty, use a default
if title == "" {
title = "Memo"
}
return title
}
func (s *RSSService) getRSSItemDescription(content string) (string, error) {
html, err := s.MarkdownService.RenderHTML([]byte(content))
if err != nil {
return "", err
}
return html, nil
}
func rssETag(content string) string {
hash := sha256.Sum256([]byte(content))
return fmt.Sprintf(`"%x"`, hash[:8])
}
// setRSSHeaders sets appropriate HTTP headers for RSS responses.
func (*RSSService) setRSSHeaders(c *echo.Context, etag string, lastModified time.Time) {
c.Response().Header().Set(echo.HeaderContentType, "application/rss+xml; charset=utf-8")
// Revalidation is mandatory because an audience change must not leave a
// previously PUBLIC memo readable from a stale feed body.
c.Response().Header().Set(echo.HeaderCacheControl, "public, no-cache")
c.Response().Header().Set("ETag", etag)
if !lastModified.IsZero() {
c.Response().Header().Set("Last-Modified", lastModified.UTC().Format(http.TimeFormat))
}
}
func getRSSHeading(ctx context.Context, stores *store.Store) (RSSHeading, error) {
settings, err := stores.GetInstanceGeneralSetting(ctx)
if err != nil {
return RSSHeading{}, err
}
if settings == nil || settings.CustomProfile == nil {
return RSSHeading{
Title: "Memos",
Description: "An open source, lightweight note-taking service. Easily capture and share your great thoughts.",
Language: "en-us",
}, nil
}
customProfile := settings.CustomProfile
return RSSHeading{
Title: customProfile.Title,
Description: customProfile.Description,
Language: "en-us",
}, nil
}

View file

@ -1,180 +0,0 @@
package rss
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/labstack/echo/v5"
"github.com/stretchr/testify/require"
"github.com/usememos/memos/internal/markdown"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store"
teststore "github.com/usememos/memos/store/test"
)
func TestPublicRSSExcludesComments(t *testing.T) {
ctx := context.Background()
stores := teststore.NewTestingStore(ctx, t)
defer stores.Close()
setInstanceAccessMode(ctx, t, stores, storepb.InstanceAccessMode_INSTANCE_ACCESS_MODE_PUBLIC)
user, err := stores.CreateUser(ctx, &store.User{
Username: "rss-comment-owner",
Role: store.RoleUser,
Email: "rss-comment-owner@example.com",
})
require.NoError(t, err)
parent, err := stores.CreateMemo(ctx, &store.Memo{
UID: "rss-public-parent",
CreatorID: user.ID,
Content: "public parent should stay in rss",
Visibility: store.Public,
})
require.NoError(t, err)
_, err = stores.CreateMemoComment(ctx, &store.Memo{
UID: "rss-public-comment",
CreatorID: user.ID,
Content: "public comment should not be in rss",
Visibility: store.Private,
}, parent.ID, user.ID)
require.NoError(t, err)
service := NewRSSService(stores, markdown.NewService())
exploreRSS := renderRSS(t, service, "/explore/rss.xml", "")
require.Contains(t, exploreRSS, "public parent should stay in rss")
require.NotContains(t, exploreRSS, "public comment should not be in rss")
userRSS := renderRSS(t, service, "/u/rss-comment-owner/rss.xml", user.Username)
require.Contains(t, userRSS, "public parent should stay in rss")
require.NotContains(t, userRSS, "public comment should not be in rss")
}
func TestPrivateInstanceDisablesRSS(t *testing.T) {
ctx := context.Background()
stores := teststore.NewTestingStore(ctx, t)
defer stores.Close()
setInstanceAccessMode(ctx, t, stores, storepb.InstanceAccessMode_INSTANCE_ACCESS_MODE_PRIVATE)
service := NewRSSService(stores, nil)
for _, test := range []struct {
name string
target string
username string
}{
{name: "explore", target: "/explore/rss.xml"},
{name: "user", target: "/u/alice/rss.xml", username: "alice"},
} {
t.Run(test.name, func(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodGet, test.target, strings.NewReader(""))
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
if test.username != "" {
c.SetPathValues(echo.PathValues{{Name: "username", Value: test.username}})
}
var err error
if test.username == "" {
err = service.GetExploreRSS(c)
} else {
err = service.GetUserRSS(c)
}
var httpError *echo.HTTPError
require.ErrorAs(t, err, &httpError)
require.Equal(t, http.StatusNotFound, httpError.Code)
})
}
}
func TestRSSIfNoneMatchRemainsStableAcrossSeconds(t *testing.T) {
ctx := context.Background()
stores := teststore.NewTestingStore(ctx, t)
defer stores.Close()
setInstanceAccessMode(ctx, t, stores, storepb.InstanceAccessMode_INSTANCE_ACCESS_MODE_PUBLIC)
user, err := stores.CreateUser(ctx, &store.User{
Username: "rss-etag-owner",
Role: store.RoleUser,
Email: "rss-etag-owner@example.com",
})
require.NoError(t, err)
_, err = stores.CreateMemo(ctx, &store.Memo{
UID: "rss-etag-stable",
CreatorID: user.ID,
Content: "stable feed content",
Visibility: store.Public,
})
require.NoError(t, err)
service := NewRSSService(stores, markdown.NewService())
explore := requestRSS(t, service, "/explore/rss.xml", "", "")
userFeed := requestRSS(t, service, "/u/rss-etag-owner/rss.xml", user.Username, "")
require.Equal(t, http.StatusOK, explore.Code)
require.Equal(t, http.StatusOK, userFeed.Code)
exploreETag := explore.Header().Get("ETag")
userETag := userFeed.Header().Get("ETag")
require.NotEmpty(t, exploreETag)
require.NotEmpty(t, userETag)
// The previous request-time channel timestamp changed once per second even
// when no feed data changed, causing these conditional reads to return 200.
time.Sleep(1100 * time.Millisecond)
explore = requestRSS(t, service, "/explore/rss.xml", "", exploreETag)
userFeed = requestRSS(t, service, "/u/rss-etag-owner/rss.xml", user.Username, userETag)
require.Equal(t, http.StatusNotModified, explore.Code)
require.Equal(t, http.StatusNotModified, userFeed.Code)
require.Equal(t, exploreETag, explore.Header().Get("ETag"))
require.Equal(t, userETag, userFeed.Header().Get("ETag"))
}
func setInstanceAccessMode(ctx context.Context, t *testing.T, stores *store.Store, mode storepb.InstanceAccessMode) {
t.Helper()
_, err := stores.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{
Key: storepb.InstanceSettingKey_ACCESS,
Value: &storepb.InstanceSetting_AccessSetting{AccessSetting: &storepb.InstanceAccessSetting{
AccessMode: mode,
}},
})
require.NoError(t, err)
}
func renderRSS(t *testing.T, service *RSSService, target string, username string) string {
t.Helper()
rec := requestRSS(t, service, target, username, "")
require.Equal(t, http.StatusOK, rec.Code)
return rec.Body.String()
}
func requestRSS(t *testing.T, service *RSSService, target, username, ifNoneMatch string) *httptest.ResponseRecorder {
t.Helper()
e := echo.New()
req := httptest.NewRequest(http.MethodGet, target, strings.NewReader(""))
req.Host = "example.com"
if ifNoneMatch != "" {
req.Header.Set("If-None-Match", ifNoneMatch)
}
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
if username != "" {
c.SetPathValues(echo.PathValues{{Name: "username", Value: username}})
}
var err error
if username == "" {
err = service.GetExploreRSS(c)
} else {
err = service.GetUserRSS(c)
}
require.NoError(t, err)
return rec
}

View file

@ -20,7 +20,6 @@ import (
"github.com/usememos/memos/server/router/fileserver"
"github.com/usememos/memos/server/router/frontend"
"github.com/usememos/memos/server/router/mcp"
"github.com/usememos/memos/server/router/rss"
"github.com/usememos/memos/store"
)
@ -66,8 +65,6 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store
// Serve frontend static files.
frontend.NewFrontendService(profile, store).Serve(ctx, echoServer)
rootGroup := echoServer.Group("")
apiV1Service := apiv1.NewAPIV1Service(s.Secret, profile, store)
s.sseHub = apiV1Service.SSEHub
@ -76,9 +73,6 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store
fileServerService := fileserver.NewFileServerService(s.Profile, s.Store, s.Secret)
fileServerService.RegisterRoutes(echoServer)
// Create and register RSS routes (needs markdown service from apiV1Service).
rss.NewRSSService(s.Store, apiV1Service.MarkdownService).RegisterRoutes(rootGroup)
// Register gRPC gateway as api v1 (includes SSE endpoint on CORS-enabled group).
if err := apiV1Service.RegisterGateway(ctx, echoServer); err != nil {
return nil, errors.Wrap(err, "failed to register gRPC gateway")

View file

@ -2,7 +2,7 @@
//
// Every other server test constructs apiv1.APIV1Service directly, which skips
// server.NewServer entirely. That leaves route registration, gRPC-gateway
// wiring, MCP/RSS/fileserver/frontend mounting, CORS and the secret bootstrap
// wiring, MCP/fileserver/frontend mounting, CORS and the secret bootstrap
// covered only by the Docker release script. These tests boot the real server
// the same way cmd/memos/main.go does so a wiring regression fails in CI.
package test
@ -289,12 +289,6 @@ func TestStartupServesEveryRegisteredRouter(t *testing.T) {
require.Equal(t, http.StatusOK, resp.StatusCode, "public GET fallback should permit anonymous form posts")
})
t.Run("rss", func(t *testing.T) {
status, body := inst.do(t, http.MethodGet, "/explore/rss.xml", "", nil)
require.Equal(t, http.StatusOK, status, "rss route should be mounted: %s", body)
require.Contains(t, string(body), "<?xml")
})
t.Run("mcp", func(t *testing.T) {
// A bare POST is enough to prove the handler is mounted; the MCP
// protocol itself is covered by server/router/mcp tests.
@ -453,10 +447,6 @@ func TestStartupPrivateInstanceGatewayPolicy(t *testing.T) {
status, _ = inst.do(t, http.MethodGet, "/api/v1/memos/startup-private-public", "", nil)
require.Equal(t, http.StatusUnauthorized, status,
"anonymous GetMemo over REST should be refused on a private instance")
status, _ = inst.do(t, http.MethodGet, "/explore/rss.xml", "", nil)
require.Equal(t, http.StatusNotFound, status,
"anonymous RSS should be unavailable on a private instance")
}
// TestStartupGatewayOmitsNullMessageFields checks the JSON the gateway actually