~netlandish/links-dev

This thread contains a patchset. You're looking at the original emails, but you may wish to use the patch review UI. Review patch
1

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

Details
Message ID
<20250730135959.7592-1-peter@netlandish.com>
Sender timestamp
1753862394
DKIM signature
missing
Download raw message
Patch: +169 -17
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
Details
Message ID
<DBPG4QLO7PY5.ZUH230ZPMAQ0@netlandish.com>
In-Reply-To
<20250730135959.7592-1-peter@netlandish.com> (view parent)
Sender timestamp
1753862800
DKIM signature
missing
Download raw message
Applied.

To git@git.code.netlandish.com:~netlandish/links
   3027338..7a249cd  master -> master
Reply to thread Export thread (mbox)