refactor(server): remove RSS feed support (#6243)
This commit is contained in:
parent
171687c456
commit
5b6d8f87bf
14 changed files with 5 additions and 778 deletions
|
|
@ -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
1
go.mod
|
|
@ -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
2
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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&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.bar@example.com`,
|
||||
expected: "<p>#<a href=\"mailto:foo.bar@example.com\">foo.bar@example.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",
|
||||
},
|
||||
}
|
||||
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 < y$</p>\n$$meta\nx < y\n$$", html)
|
||||
}
|
||||
|
||||
func TestRenderMarkdownPreservesLineBreakAfterTag(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -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") ||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue