Subject: [PATCH links] Add authentication hash to user's feed RSS. Users can now add their private feeds to their favorite RSS reader.

From: Peter Sanchez <[email-masked]>
Date: Wed, 30 Jul 2025 07:59:54 -0600

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": "[email-masked]",
+                    "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": "[email-masked]",
+                    "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