Fixes: https://todo.code.netlandish.com/~netlandish/links/114
Changelog-fixed: RSS now works for users private feeds
---
core/routes.go | 30 ++++++++++++++-
core/routes_test.go | 76 +++++++++++++++++++++++++++++++++++++
core/samples/user_feed.json | 57 ++++++++++++++++++++++++++++
helpers.go | 23 ++++-------
4 files changed, 169 insertions(+), 17 deletions(-)
create mode 100644 core/samples/user_feed.json
diff --git a/core/routes.go b/core/routes.go
index b9e0dd3..70abc64 100644
--- a/core/routes.go
+++ b/core/routes.go
@@ -26,6 +26,7 @@ import (
gaccounts "netlandish.com/x/gobwebs/accounts"
"netlandish.com/x/gobwebs/auth"
"netlandish.com/x/gobwebs/core"
+ "netlandish.com/x/gobwebs/crypto"
"netlandish.com/x/gobwebs/database"
"netlandish.com/x/gobwebs/messages"
"netlandish.com/x/gobwebs/server"
@@ -58,11 +59,11 @@ func (s *Service) RegisterRoutes() {
s.Group.GET("/click/:hash", s.OrgLinkRedirect).Name = s.RouteName("link_redirect")
s.Group.GET("/tag-autocomplete", s.TagAutocomplete).Name = s.RouteName("tag_autocomplete")
s.Group.GET("/bookmarks/:hash", s.GetBookmarks).Name = s.RouteName("bookmarks")
+ s.Group.GET("/feed/rss/:hash", s.UserFeed).Name = s.RouteName("user_feed_rss")
s.Group.Use(auth.AuthRequired())
s.Group.GET("/:slug/follow-toggle/:action", s.FollowToggle).Name = s.RouteName("follow-toggle")
s.Group.GET("/feed", s.UserFeed).Name = s.RouteName("user_feed")
- s.Group.GET("/feed/rss", s.UserFeed).Name = s.RouteName("user_feed_rss")
s.Group.GET("/feed/following", s.UserFeedFollowing).Name = s.RouteName("user_feed_following")
s.Group.GET("/:slug/domains/add", s.DomainCreate).Name = s.RouteName("domain_add")
s.Group.POST("/:slug/domains/add", s.DomainCreate).Name = s.RouteName("domain_add_post")
@@ -1755,6 +1756,22 @@ func (s *Service) UserFeedFollowing(c echo.Context) error {
func (s *Service) UserFeed(c echo.Context) error {
gctx := c.(*server.Context)
user := gctx.User.(*models.User)
+ if !user.IsAuthenticated() && links.IsRSS(c.Path()) {
+ hash := c.Param("hash")
+ uid, err := links.DecodeRSSUserHash(c, hash)
+ if err != nil {
+ return err
+ }
+ user, err = models.GetUser(c.Request().Context(), uid, true)
+ if err != nil {
+ return err
+ }
+
+ // Add it to the context because links.Execute needs it for this
+ // specific query
+ ctx := auth.Context(c.Request().Context(), user)
+ c.SetRequest(c.Request().WithContext(ctx))
+ }
type GraphQLResponse struct {
OrgLinks struct {
Result []models.OrgLink `json:"result"`
@@ -1903,6 +1920,15 @@ func (s *Service) UserFeed(c echo.Context) error {
return links.ServerRSSFeed(c.Response(), rss)
}
+
+ // We encrypt the user ID and use the resulting hash
+ userID := fmt.Sprint(user.ID)
+ kw := crypto.KWForContext(c.Request().Context())
+ rssHash, err := crypto.EncryptBytesB64URL([]byte(userID), kw.Keys[0])
+ if err != nil {
+ rssHash = ""
+ }
+
gmap := gobwebs.Map{
"pd": pd,
"links": orgLinks,
@@ -1911,7 +1937,7 @@ func (s *Service) UserFeed(c echo.Context) error {
"tagFilter": strings.ReplaceAll(tag, ",", ", "),
"excludeTagFilter": strings.ReplaceAll(excludeTag, ",", ", "),
"advancedSearch": true,
- "rssURL": c.Echo().Reverse("core:user_feed_rss"),
+ "rssURL": c.Echo().Reverse("core:user_feed_rss", rssHash),
"navFlag": "feed",
"tagCloud": result.OrgLinks.TagCloud,
}
diff --git a/core/routes_test.go b/core/routes_test.go
index 2a9fcde..452931c 100644
--- a/core/routes_test.go
+++ b/core/routes_test.go
@@ -18,6 +18,7 @@ import (
"github.com/jarcoal/httpmock"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/require"
+ "netlandish.com/x/gobwebs/crypto"
"netlandish.com/x/gobwebs/database"
"netlandish.com/x/gobwebs/server"
)
@@ -558,4 +559,79 @@ func TestHandlers(t *testing.T) {
c.NoError(err)
c.Equal(http.StatusMovedPermanently, recorder.Code)
})
+
+ t.Run("user feed rss with valid hash", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+ jsonResponse, err := httpmock.NewJsonResponder(http.StatusOK, httpmock.File("samples/user_feed.json"))
+ c.NoError(err)
+ httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query", jsonResponse)
+
+ // First, we need to create a hash using a request that has crypto context
+ // Create a temporary authenticated request to generate the hash
+ tempRequest := httptest.NewRequest(http.MethodGet, "/", nil)
+ tempRecorder := httptest.NewRecorder()
+ tempCtx := &server.Context{
+ Server: srv,
+ Context: e.NewContext(tempRequest, tempRecorder),
+ User: loggedInUser,
+ }
+
+ // Use a custom handler to extract the RSS hash
+ var rssHash string
+ hashExtractor := func(c echo.Context) error {
+ kw := crypto.KWForContext(c.Request().Context())
+ hash, err := crypto.EncryptBytesB64URL([]byte("1"), kw.Keys[0])
+ if err != nil {
+ return err
+ }
+ rssHash = hash
+ return nil
+ }
+
+ err = test.MakeRequestWithDomain(srv, hashExtractor, tempCtx, domains[0])
+ c.NoError(err)
+ c.NotEmpty(rssHash)
+
+ // Now test the RSS feed with the generated hash
+ request := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/feed/rss/%s", rssHash), nil)
+ recorder := httptest.NewRecorder()
+ // Use an unauthenticated user to simulate external RSS reader access
+ ctx := &server.Context{
+ Server: srv,
+ Context: e.NewContext(request, recorder),
+ User: test.NewTestUser(0, false, false, false, false), // Unauthenticated user
+ }
+ ctx.SetPath("/feed/rss/:hash")
+ ctx.SetParamNames("hash")
+ ctx.SetParamValues(rssHash)
+ err = test.MakeRequestWithDomain(srv, coreService.UserFeed, ctx, domains[0])
+ c.NoError(err)
+ c.Equal(http.StatusOK, recorder.Code)
+ c.Equal("application/xml; charset=UTF-8", recorder.Header().Get("Content-Type"))
+ rssBody := recorder.Body.String()
+ c.True(strings.Contains(rssBody, "Feed Link One"))
+ c.True(strings.Contains(rssBody, "Feed Link Two"))
+ })
+
+ t.Run("user feed rss with invalid hash", func(t *testing.T) {
+ request := httptest.NewRequest(http.MethodGet, "/feed/rss/invalid-hash-123", nil)
+ recorder := httptest.NewRecorder()
+ // Use an unauthenticated user to simulate external RSS reader access
+ ctx := &server.Context{
+ Server: srv,
+ Context: e.NewContext(request, recorder),
+ User: test.NewTestUser(0, false, false, false, false), // Unauthenticated user
+ }
+ ctx.SetPath("/feed/rss/:hash")
+ ctx.SetParamNames("hash")
+ ctx.SetParamValues("invalid-hash-123")
+ err = test.MakeRequestWithDomain(srv, coreService.UserFeed, ctx, domains[0])
+ // The handler should return an error (404) when the hash is invalid
+ c.Error(err)
+ // Check that it's specifically a 404 error
+ httpErr, ok := err.(*echo.HTTPError)
+ c.True(ok)
+ c.Equal(http.StatusNotFound, httpErr.Code)
+ })
}
diff --git a/core/samples/user_feed.json b/core/samples/user_feed.json
new file mode 100644
index 0000000..cf96059
--- /dev/null
+++ b/core/samples/user_feed.json
@@ -0,0 +1,57 @@
+{
+ "data": {
+ "getFeed": {
+ "result": [
+ {
+ "id": 1,
+ "title": "Feed Link One",
+ "url": "https://example.com/link1",
+ "description": "Description for link one",
+ "author": "user@example.com",
+ "orgSlug": "personal-org",
+ "createdOn": "2025-01-15T10:00:00Z",
+ "tags": [
+ {
+ "id": 1,
+ "name": "personal",
+ "slug": "personal"
+ }
+ ]
+ },
+ {
+ "id": 2,
+ "title": "Feed Link Two",
+ "url": "https://example.com/link2",
+ "description": "Description for link two",
+ "author": "user@example.com",
+ "orgSlug": "personal-org",
+ "createdOn": "2025-01-14T10:00:00Z",
+ "tags": [
+ {
+ "id": 2,
+ "name": "tech",
+ "slug": "tech"
+ }
+ ]
+ }
+ ],
+ "pageInfo": {
+ "cursor": "feedcursor123",
+ "hasNextPage": false,
+ "hasPrevPage": false
+ },
+ "tagCloud": [
+ {
+ "id": 1,
+ "name": "personal",
+ "slug": "personal"
+ },
+ {
+ "id": 2,
+ "name": "tech",
+ "slug": "tech"
+ }
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/helpers.go b/helpers.go
index c10907a..431d08a 100644
--- a/helpers.go
+++ b/helpers.go
@@ -745,10 +745,7 @@ func ProcessTags(ctx context.Context, tags []string) ([]int, error) {
tagIDs := make([]int, 0)
for _, tag := range tags {
tag := strings.TrimSpace(tag)
- if strings.HasPrefix(tag, "#") {
- // Remove any leading hash mark
- tag = tag[1:]
- }
+ tag = strings.TrimPrefix(tag, "#")
if tag != "" {
slug := Slugify(tag)
if len(tag) > 50 || len(slug) > 50 {
@@ -796,19 +793,15 @@ func NewTagQuery(t, c string) *TagQuery {
func (t TagQuery) GetSubQuery(inputTag, inputExcludeTag *string) (string, []any, error) {
var tags = make([]string, 0)
if inputTag != nil && *inputTag != "" {
- tag := strings.Replace(*inputTag, " ", "", -1)
- if strings.HasSuffix(tag, ",") {
- tag = tag[:len(tag)-1]
- }
+ tag := strings.ReplaceAll(*inputTag, " ", "")
+ tag = strings.TrimSuffix(tag, ".")
tags = strings.Split(tag, ",")
}
var excludeTags = make([]string, 0)
if inputExcludeTag != nil && *inputExcludeTag != "" {
- tag := strings.Replace(*inputExcludeTag, " ", "", -1)
- if strings.HasSuffix(tag, ",") {
- tag = tag[:len(tag)-1]
- }
+ tag := strings.ReplaceAll(*inputExcludeTag, " ", "")
+ tag = strings.TrimSuffix(tag, ".")
excludeTags = strings.Split(tag, ",")
}
@@ -875,9 +868,9 @@ func ParseSearch(s string) string {
// This is used for to_tsquery searches (tag autocomplete)
if len(word) > 0 {
word = strings.TrimSpace(word)
- word = strings.Replace(word, ":", "\\:", -1)
- word = strings.Replace(word, "|", "\\|", -1)
- word = strings.Replace(word, "!", "\\!", -1)
+ word = strings.ReplaceAll(word, ":", "\\:")
+ word = strings.ReplaceAll(word, "|", "\\|")
+ word = strings.ReplaceAll(word, "!", "\\!")
if !strings.HasPrefix(word, "-") {
word = word + ":*"
}
--
2.49.1