Peter Sanchez: 1 Add authentication hash to user's feed RSS. Users can now add their private feeds to their favorite RSS reader. 4 files changed, 169 insertions(+), 17 deletions(-)
Copy & paste the following snippet into your terminal to import this patchset into git:
curl -s https://lists.code.netlandish.com/~netlandish/links-dev/patches/162/mbox | git am -3Learn more about email & git
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
Applied. To git@git.code.netlandish.com:~netlandish/links 3027338..7a249cd master -> master