Skip to content

Commit

Permalink
[bugfix] Tidy up rss feed serving; don't error on empty feed (#1970)
Browse files Browse the repository at this point in the history
* [bugfix] Tidy up rss feed serving; don't error on empty feed

* fall back to account creation time as rss feed update time

* return feed early when account has no eligible statuses
  • Loading branch information
tsmethurst authored Jul 10, 2023
1 parent a29b5af commit ca5492b
Show file tree
Hide file tree
Showing 3 changed files with 262 additions and 121 deletions.
163 changes: 116 additions & 47 deletions internal/processing/account/rss.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,82 +27,151 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)

const rssFeedLength = 20
const (
rssFeedLength = 20
)

type GetRSSFeed func() (string, gtserror.WithCode)

// GetRSSFeedForUsername returns a function to return the RSS feed of a local account
// with the given username, and the last-modified time (time that the account last
// posted a status eligible to be included in the rss feed).
//
// To save db calls, callers to this function should only call the returned GetRSSFeed
// func if the last-modified time is newer than the last-modified time they have cached.
//
// If the account has not yet posted an RSS-eligible status, the returned last-modified
// time will be zero, and the GetRSSFeed func will return a valid RSS xml with no items.
func (p *Processor) GetRSSFeedForUsername(ctx context.Context, username string) (GetRSSFeed, time.Time, gtserror.WithCode) {
var (
never = time.Time{}
)

// GetRSSFeedForUsername returns RSS feed for the given local username.
func (p *Processor) GetRSSFeedForUsername(ctx context.Context, username string) (func() (string, gtserror.WithCode), time.Time, gtserror.WithCode) {
account, err := p.state.DB.GetAccountByUsernameDomain(ctx, username, "")
if err != nil {
if err == db.ErrNoEntries {
return nil, time.Time{}, gtserror.NewErrorNotFound(errors.New("GetRSSFeedForUsername: account not found"))
if errors.Is(err, db.ErrNoEntries) {
// Simply no account with this username.
err = gtserror.New("account not found")
return nil, never, gtserror.NewErrorNotFound(err)
}
return nil, time.Time{}, gtserror.NewErrorInternalError(fmt.Errorf("GetRSSFeedForUsername: db error: %s", err))

// Real db error.
err = gtserror.Newf("db error getting account %s: %w", username, err)
return nil, never, gtserror.NewErrorInternalError(err)
}

// Ensure account has rss feed enabled.
if !*account.EnableRSS {
return nil, time.Time{}, gtserror.NewErrorNotFound(errors.New("GetRSSFeedForUsername: account RSS feed not enabled"))
err = gtserror.New("account RSS feed not enabled")
return nil, never, gtserror.NewErrorNotFound(err)
}

lastModified, err := p.state.DB.GetAccountLastPosted(ctx, account.ID, true)
if err != nil {
return nil, time.Time{}, gtserror.NewErrorInternalError(fmt.Errorf("GetRSSFeedForUsername: db error: %s", err))
// LastModified time is needed by callers to check freshness for cacheing.
// This might be a zero time.Time if account has never posted a status that's
// eligible to appear in the RSS feed; that's fine.
lastPostAt, err := p.state.DB.GetAccountLastPosted(ctx, account.ID, true)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err = gtserror.Newf("db error getting account %s last posted: %w", username, err)
return nil, never, gtserror.NewErrorInternalError(err)
}

return func() (string, gtserror.WithCode) {
statuses, err := p.state.DB.GetAccountWebStatuses(ctx, account.ID, rssFeedLength, "")
if err != nil && err != db.ErrNoEntries {
return "", gtserror.NewErrorInternalError(fmt.Errorf("GetRSSFeedForUsername: db error: %s", err))
}

// Assemble author namestring once only.
author := "@" + account.Username + "@" + config.GetAccountDomain()
title := "Posts from " + author
description := "Posts from " + author
link := &feeds.Link{Href: account.URL}

var image *feeds.Image
if account.AvatarMediaAttachmentID != "" {
if account.AvatarMediaAttachment == nil {
avatar, err := p.state.DB.GetAttachmentByID(ctx, account.AvatarMediaAttachmentID)
if err != nil {
return "", gtserror.NewErrorInternalError(fmt.Errorf("GetRSSFeedForUsername: db error fetching avatar attachment: %s", err))
}
account.AvatarMediaAttachment = avatar
}
image = &feeds.Image{
Url: account.AvatarMediaAttachment.Thumbnail.URL,
Title: "Avatar for " + author,
Link: account.URL,
}

// Derive image/thumbnail for this account (may be nil).
image, errWithCode := p.rssImageForAccount(ctx, account, author)
if errWithCode != nil {
return "", errWithCode
}

feed := &feeds.Feed{
Title: title,
Description: description,
Link: link,
Title: "Posts from " + author,
Description: "Posts from " + author,
Link: &feeds.Link{Href: account.URL},
Image: image,
}

for i, s := range statuses {
// take the date of the first (ie., latest) status as feed updated value
if i == 0 {
feed.Updated = s.UpdatedAt
}
// If the account has never posted anything, just use
// account creation time as Updated value for the feed;
// we could use time.Now() here but this would likely
// mess up cacheing; we want something determinate.
//
// We can also return early rather than wasting a db call,
// since we already know there's no eligible statuses.
if lastPostAt.IsZero() {
feed.Updated = account.CreatedAt
return stringifyFeed(feed)
}

// Account has posted at least one status that's
// eligible to appear in the RSS feed.
//
// Reuse the lastPostAt value for feed.Updated.
feed.Updated = lastPostAt

item, err := p.tc.StatusToRSSItem(ctx, s)
// Retrieve latest statuses as they'd be shown on the web view of the account profile.
statuses, err := p.state.DB.GetAccountWebStatuses(ctx, account.ID, rssFeedLength, "")
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err = fmt.Errorf("db error getting account web statuses: %w", err)
return "", gtserror.NewErrorInternalError(err)
}

// Add each status to the rss feed.
for _, status := range statuses {
item, err := p.tc.StatusToRSSItem(ctx, status)
if err != nil {
return "", gtserror.NewErrorInternalError(fmt.Errorf("GetRSSFeedForUsername: error converting status to feed item: %s", err))
err = gtserror.Newf("error converting status to feed item: %w", err)
return "", gtserror.NewErrorInternalError(err)
}

feed.Add(item)
}

rss, err := feed.ToRss()
return stringifyFeed(feed)
}, lastPostAt, nil
}

func (p *Processor) rssImageForAccount(ctx context.Context, account *gtsmodel.Account, author string) (*feeds.Image, gtserror.WithCode) {
if account.AvatarMediaAttachmentID == "" {
// No image, no problem!
return nil, nil
}

// Ensure account avatar attachment populated.
if account.AvatarMediaAttachment == nil {
var err error
account.AvatarMediaAttachment, err = p.state.DB.GetAttachmentByID(ctx, account.AvatarMediaAttachmentID)
if err != nil {
return "", gtserror.NewErrorInternalError(fmt.Errorf("GetRSSFeedForUsername: error converting feed to rss string: %s", err))
if errors.Is(err, db.ErrNoEntries) {
// No attachment found with this ID (race condition?).
return nil, nil
}

// Real db error.
err = gtserror.Newf("db error fetching avatar media attachment: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
}

return &feeds.Image{
Url: account.AvatarMediaAttachment.Thumbnail.URL,
Title: "Avatar for " + author,
Link: account.URL,
}, nil
}

func stringifyFeed(feed *feeds.Feed) (string, gtserror.WithCode) {
// Stringify the feed. Even with no statuses,
// this will still produce valid rss xml.
rss, err := feed.ToRss()
if err != nil {
err := gtserror.Newf("error converting feed to rss string: %w", err)
return "", gtserror.NewErrorInternalError(err)
}

return rss, nil
}, lastModified, nil
return rss, nil
}
28 changes: 28 additions & 0 deletions internal/processing/account/rss_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,34 @@ func (suite *GetRSSTestSuite) TestGetAccountRSSZork() {
suite.Equal("<?xml version=\"1.0\" encoding=\"UTF-8\"?><rss version=\"2.0\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">\n <channel>\n <title>Posts from @the_mighty_zork@localhost:8080</title>\n <link>http://localhost:8080/@the_mighty_zork</link>\n <description>Posts from @the_mighty_zork@localhost:8080</description>\n <pubDate>Wed, 20 Oct 2021 10:40:37 +0000</pubDate>\n <lastBuildDate>Wed, 20 Oct 2021 10:40:37 +0000</lastBuildDate>\n <image>\n <url>http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpg</url>\n <title>Avatar for @the_mighty_zork@localhost:8080</title>\n <link>http://localhost:8080/@the_mighty_zork</link>\n </image>\n <item>\n <title>introduction post</title>\n <link>http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY</link>\n <description>@the_mighty_zork@localhost:8080 made a new post: &#34;hello everyone!&#34;</description>\n <content:encoded><![CDATA[hello everyone!]]></content:encoded>\n <author>@the_mighty_zork@localhost:8080</author>\n <guid>http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY</guid>\n <pubDate>Wed, 20 Oct 2021 10:40:37 +0000</pubDate>\n <source>http://localhost:8080/@the_mighty_zork/feed.rss</source>\n </item>\n </channel>\n</rss>", feed)
}

func (suite *GetRSSTestSuite) TestGetAccountRSSZorkNoPosts() {
ctx := context.Background()

// Get all of zork's posts.
statuses, err := suite.db.GetAccountStatuses(ctx, suite.testAccounts["local_account_1"].ID, 0, false, false, "", "", false, false)
if err != nil {
suite.FailNow(err.Error())
}

// Now delete them! Hahaha!
for _, status := range statuses {
if err := suite.db.DeleteStatusByID(ctx, status.ID); err != nil {
suite.FailNow(err.Error())
}
}

getFeed, lastModified, err := suite.accountProcessor.GetRSSFeedForUsername(ctx, "the_mighty_zork")
suite.NoError(err)
suite.Empty(lastModified)

feed, err := getFeed()
suite.NoError(err)

fmt.Println(feed)

suite.Equal("<?xml version=\"1.0\" encoding=\"UTF-8\"?><rss version=\"2.0\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">\n <channel>\n <title>Posts from @the_mighty_zork@localhost:8080</title>\n <link>http://localhost:8080/@the_mighty_zork</link>\n <description>Posts from @the_mighty_zork@localhost:8080</description>\n <pubDate>Fri, 20 May 2022 11:09:18 +0000</pubDate>\n <lastBuildDate>Fri, 20 May 2022 11:09:18 +0000</lastBuildDate>\n <image>\n <url>http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpg</url>\n <title>Avatar for @the_mighty_zork@localhost:8080</title>\n <link>http://localhost:8080/@the_mighty_zork</link>\n </image>\n </channel>\n</rss>", feed)
}

func TestGetRSSTestSuite(t *testing.T) {
suite.Run(t, new(GetRSSTestSuite))
}
Loading

0 comments on commit ca5492b

Please sign in to comment.