From 6f6e89e2715c9ecbadda6b8dbe5227995348dae8 Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Wed, 8 Jun 2022 20:22:49 +0200 Subject: [PATCH] [feature] Add paging via `Link` header for notifications and account statuses (#629) * test link headers * page get account statuses properly * page get notifications * add util func for packaging timeline responses * return timelined stuff from accountstatusesget * rename timeline response * use new convenience function * go fmt --- internal/api/client/account/statuses.go | 7 +- internal/api/client/account/statuses_test.go | 44 ++++++- .../api/client/favourites/favouritesget.go | 2 +- .../client/notification/notificationsget.go | 7 +- internal/api/client/timeline/home.go | 2 +- internal/api/client/timeline/public.go | 2 +- internal/api/model/notification.go | 21 ++++ internal/api/model/timeline.go | 8 +- internal/processing/account.go | 2 +- internal/processing/account/account.go | 2 +- internal/processing/account/getstatuses.go | 39 +++++-- internal/processing/notification.go | 21 +++- internal/processing/notification_test.go | 12 +- internal/processing/processor.go | 10 +- internal/processing/statustimeline.go | 107 ++++++++---------- internal/util/timeline.go | 104 +++++++++++++++++ internal/web/profile.go | 11 +- 17 files changed, 303 insertions(+), 98 deletions(-) create mode 100644 internal/util/timeline.go diff --git a/internal/api/client/account/statuses.go b/internal/api/client/account/statuses.go index b440e582a..18b551fcc 100644 --- a/internal/api/client/account/statuses.go +++ b/internal/api/client/account/statuses.go @@ -222,12 +222,15 @@ func (m *Module) AccountStatusesGETHandler(c *gin.Context) { publicOnly = i } - statuses, errWithCode := m.processor.AccountStatusesGet(c.Request.Context(), authed, targetAcctID, limit, excludeReplies, excludeReblogs, maxID, minID, pinnedOnly, mediaOnly, publicOnly) + resp, errWithCode := m.processor.AccountStatusesGet(c.Request.Context(), authed, targetAcctID, limit, excludeReplies, excludeReblogs, maxID, minID, pinnedOnly, mediaOnly, publicOnly) if errWithCode != nil { l.Debugf("error from processor account statuses get: %s", errWithCode) c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) return } - c.JSON(http.StatusOK, statuses) + if resp.LinkHeader != "" { + c.Header("Link", resp.LinkHeader) + } + c.JSON(http.StatusOK, resp.Items) } diff --git a/internal/api/client/account/statuses_test.go b/internal/api/client/account/statuses_test.go index 0e95d47fc..1f935896c 100644 --- a/internal/api/client/account/statuses_test.go +++ b/internal/api/client/account/statuses_test.go @@ -37,7 +37,47 @@ type AccountStatusesTestSuite struct { AccountStandardTestSuite } -func (suite *AccountStatusesTestSuite) TestGetStatusesMediaOnly() { +func (suite *AccountStatusesTestSuite) TestGetStatusesPublicOnly() { + // set up the request + // we're getting statuses of admin + targetAccount := suite.testAccounts["admin_account"] + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodGet, nil, fmt.Sprintf("/api/v1/accounts/%s/statuses?limit=20&only_media=false&only_public=true", targetAccount.ID), "") + ctx.Params = gin.Params{ + gin.Param{ + Key: account.IDKey, + Value: targetAccount.ID, + }, + } + + // call the handler + suite.accountModule.AccountStatusesGETHandler(ctx) + + // 1. we should have OK because our request was valid + suite.Equal(http.StatusOK, recorder.Code) + + // 2. we should have no error message in the result body + result := recorder.Result() + defer result.Body.Close() + + // check the response + b, err := ioutil.ReadAll(result.Body) + assert.NoError(suite.T(), err) + + // unmarshal the returned statuses + apimodelStatuses := []*apimodel.Status{} + err = json.Unmarshal(b, &apimodelStatuses) + suite.NoError(err) + suite.NotEmpty(apimodelStatuses) + + for _, s := range apimodelStatuses { + suite.Equal(apimodel.VisibilityPublic, s.Visibility) + } + + suite.Equal(`; rel="next", ; rel="prev"`, result.Header.Get("link")) +} + +func (suite *AccountStatusesTestSuite) TestGetStatusesPublicOnlyMediaOnly() { // set up the request // we're getting statuses of admin targetAccount := suite.testAccounts["admin_account"] @@ -74,6 +114,8 @@ func (suite *AccountStatusesTestSuite) TestGetStatusesMediaOnly() { suite.NotEmpty(s.MediaAttachments) suite.Equal(apimodel.VisibilityPublic, s.Visibility) } + + suite.Equal(`; rel="next", ; rel="prev"`, result.Header.Get("link")) } func TestAccountStatusesTestSuite(t *testing.T) { diff --git a/internal/api/client/favourites/favouritesget.go b/internal/api/client/favourites/favouritesget.go index d112e9b95..5a317b2ea 100644 --- a/internal/api/client/favourites/favouritesget.go +++ b/internal/api/client/favourites/favouritesget.go @@ -61,5 +61,5 @@ func (m *Module) FavouritesGETHandler(c *gin.Context) { if resp.LinkHeader != "" { c.Header("Link", resp.LinkHeader) } - c.JSON(http.StatusOK, resp.Statuses) + c.JSON(http.StatusOK, resp.Items) } diff --git a/internal/api/client/notification/notificationsget.go b/internal/api/client/notification/notificationsget.go index 0a56ee80b..b6f7cdd01 100644 --- a/internal/api/client/notification/notificationsget.go +++ b/internal/api/client/notification/notificationsget.go @@ -74,12 +74,15 @@ func (m *Module) NotificationsGETHandler(c *gin.Context) { sinceID = sinceIDString } - notifs, errWithCode := m.processor.NotificationsGet(c.Request.Context(), authed, limit, maxID, sinceID) + resp, errWithCode := m.processor.NotificationsGet(c.Request.Context(), authed, limit, maxID, sinceID) if errWithCode != nil { l.Debugf("error processing notifications get: %s", errWithCode.Error()) c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) return } - c.JSON(http.StatusOK, notifs) + if resp.LinkHeader != "" { + c.Header("Link", resp.LinkHeader) + } + c.JSON(http.StatusOK, resp.Items) } diff --git a/internal/api/client/timeline/home.go b/internal/api/client/timeline/home.go index dfe1a0db0..47bc2943e 100644 --- a/internal/api/client/timeline/home.go +++ b/internal/api/client/timeline/home.go @@ -171,5 +171,5 @@ func (m *Module) HomeTimelineGETHandler(c *gin.Context) { if resp.LinkHeader != "" { c.Header("Link", resp.LinkHeader) } - c.JSON(http.StatusOK, resp.Statuses) + c.JSON(http.StatusOK, resp.Items) } diff --git a/internal/api/client/timeline/public.go b/internal/api/client/timeline/public.go index 075d9267c..06da6e947 100644 --- a/internal/api/client/timeline/public.go +++ b/internal/api/client/timeline/public.go @@ -171,5 +171,5 @@ func (m *Module) PublicTimelineGETHandler(c *gin.Context) { if resp.LinkHeader != "" { c.Header("Link", resp.LinkHeader) } - c.JSON(http.StatusOK, resp.Statuses) + c.JSON(http.StatusOK, resp.Items) } diff --git a/internal/api/model/notification.go b/internal/api/model/notification.go index 8f929cab4..efcd2431b 100644 --- a/internal/api/model/notification.go +++ b/internal/api/model/notification.go @@ -43,3 +43,24 @@ type Notification struct { // Status that was the object of the notification, e.g. in mentions, reblogs, favourites, or polls. Status *Status `json:"status,omitempty"` } + +/* + The below functions are added onto the apimodel notification so that it satisfies + the Timelineable interface in internal/timeline. +*/ + +func (n *Notification) GetID() string { + return n.ID +} + +func (n *Notification) GetAccountID() string { + return "" +} + +func (n *Notification) GetBoostOfID() string { + return "" +} + +func (n *Notification) GetBoostOfAccountID() string { + return "" +} diff --git a/internal/api/model/timeline.go b/internal/api/model/timeline.go index 66a5263b8..71d839ed2 100644 --- a/internal/api/model/timeline.go +++ b/internal/api/model/timeline.go @@ -18,9 +18,11 @@ package model -// StatusTimelineResponse wraps a slice of statuses, ready to be serialized, along with the Link +import "github.com/superseriousbusiness/gotosocial/internal/timeline" + +// TimelineResponse wraps a slice of timelineables, ready to be serialized, along with the Link // header for the previous and next queries, to be returned to the client. -type StatusTimelineResponse struct { - Statuses []*Status +type TimelineResponse struct { + Items []timeline.Timelineable LinkHeader string } diff --git a/internal/processing/account.go b/internal/processing/account.go index 25f024785..43c4f65e6 100644 --- a/internal/processing/account.go +++ b/internal/processing/account.go @@ -46,7 +46,7 @@ func (p *processor) AccountUpdate(ctx context.Context, authed *oauth.Auth, form return p.accountProcessor.Update(ctx, authed.Account, form) } -func (p *processor) AccountStatusesGet(ctx context.Context, authed *oauth.Auth, targetAccountID string, limit int, excludeReplies bool, excludeReblogs bool, maxID string, minID string, pinnedOnly bool, mediaOnly bool, publicOnly bool) ([]apimodel.Status, gtserror.WithCode) { +func (p *processor) AccountStatusesGet(ctx context.Context, authed *oauth.Auth, targetAccountID string, limit int, excludeReplies bool, excludeReblogs bool, maxID string, minID string, pinnedOnly bool, mediaOnly bool, publicOnly bool) (*apimodel.TimelineResponse, gtserror.WithCode) { return p.accountProcessor.StatusesGet(ctx, authed.Account, targetAccountID, limit, excludeReplies, excludeReblogs, maxID, minID, pinnedOnly, mediaOnly, publicOnly) } diff --git a/internal/processing/account/account.go b/internal/processing/account/account.go index 7668da02c..2a27fc743 100644 --- a/internal/processing/account/account.go +++ b/internal/processing/account/account.go @@ -55,7 +55,7 @@ type Processor interface { Update(ctx context.Context, account *gtsmodel.Account, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, error) // StatusesGet fetches a number of statuses (in time descending order) from the given account, filtered by visibility for // the account given in authed. - StatusesGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string, limit int, excludeReplies bool, excludeReblogs bool, maxID string, minID string, pinned bool, mediaOnly bool, publicOnly bool) ([]apimodel.Status, gtserror.WithCode) + StatusesGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string, limit int, excludeReplies bool, excludeReblogs bool, maxID string, minID string, pinned bool, mediaOnly bool, publicOnly bool) (*apimodel.TimelineResponse, gtserror.WithCode) // FollowersGet fetches a list of the target account's followers. FollowersGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string) ([]apimodel.Account, gtserror.WithCode) // FollowingGet fetches a list of the accounts that target account is following. diff --git a/internal/processing/account/getstatuses.go b/internal/processing/account/getstatuses.go index c185302c5..90bf8e06e 100644 --- a/internal/processing/account/getstatuses.go +++ b/internal/processing/account/getstatuses.go @@ -26,9 +26,11 @@ "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/timeline" + "github.com/superseriousbusiness/gotosocial/internal/util" ) -func (p *processor) StatusesGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string, limit int, excludeReplies bool, excludeReblogs bool, maxID string, minID string, pinnedOnly bool, mediaOnly bool, publicOnly bool) ([]apimodel.Status, gtserror.WithCode) { +func (p *processor) StatusesGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string, limit int, excludeReplies bool, excludeReblogs bool, maxID string, minID string, pinnedOnly bool, mediaOnly bool, publicOnly bool) (*apimodel.TimelineResponse, gtserror.WithCode) { if requestingAccount != nil { if blocked, err := p.db.IsBlocked(ctx, requestingAccount.ID, targetAccountID, true); err != nil { return nil, gtserror.NewErrorInternalError(err) @@ -37,29 +39,48 @@ func (p *processor) StatusesGet(ctx context.Context, requestingAccount *gtsmodel } } - apiStatuses := []apimodel.Status{} - statuses, err := p.db.GetAccountStatuses(ctx, targetAccountID, limit, excludeReplies, excludeReblogs, maxID, minID, pinnedOnly, mediaOnly, publicOnly) if err != nil { if err == db.ErrNoEntries { - return apiStatuses, nil + return util.EmptyTimelineResponse(), nil } return nil, gtserror.NewErrorInternalError(err) } + var filtered []*gtsmodel.Status for _, s := range statuses { visible, err := p.filter.StatusVisible(ctx, s, requestingAccount) - if err != nil || !visible { - continue + if err == nil && visible { + filtered = append(filtered, s) } + } - apiStatus, err := p.tc.StatusToAPIStatus(ctx, s, requestingAccount) + if len(filtered) == 0 { + return util.EmptyTimelineResponse(), nil + } + + timelineables := []timeline.Timelineable{} + for _, i := range filtered { + apiStatus, err := p.tc.StatusToAPIStatus(ctx, i, requestingAccount) if err != nil { return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status to api: %s", err)) } - apiStatuses = append(apiStatuses, *apiStatus) + timelineables = append(timelineables, apiStatus) } - return apiStatuses, nil + return util.PackageTimelineableResponse(util.TimelineableResponseParams{ + Items: timelineables, + Path: fmt.Sprintf("/api/v1/accounts/%s/statuses", targetAccountID), + NextMaxIDValue: timelineables[len(timelineables)-1].GetID(), + PrevMinIDValue: timelineables[0].GetID(), + Limit: limit, + ExtraQueryParams: []string{ + fmt.Sprintf("exclude_replies=%t", excludeReplies), + fmt.Sprintf("exclude_reblogs=%t", excludeReblogs), + fmt.Sprintf("pinned_only=%t", pinnedOnly), + fmt.Sprintf("only_media=%t", mediaOnly), + fmt.Sprintf("only_public=%t", publicOnly), + }, + }) } diff --git a/internal/processing/notification.go b/internal/processing/notification.go index e976952e8..ee0e4ae34 100644 --- a/internal/processing/notification.go +++ b/internal/processing/notification.go @@ -26,9 +26,11 @@ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/timeline" + "github.com/superseriousbusiness/gotosocial/internal/util" ) -func (p *processor) NotificationsGet(ctx context.Context, authed *oauth.Auth, limit int, maxID string, sinceID string) ([]*apimodel.Notification, gtserror.WithCode) { +func (p *processor) NotificationsGet(ctx context.Context, authed *oauth.Auth, limit int, maxID string, sinceID string) (*apimodel.TimelineResponse, gtserror.WithCode) { l := logrus.WithField("func", "NotificationsGet") notifs, err := p.db.GetNotifications(ctx, authed.Account.ID, limit, maxID, sinceID) @@ -36,15 +38,26 @@ func (p *processor) NotificationsGet(ctx context.Context, authed *oauth.Auth, li return nil, gtserror.NewErrorInternalError(err) } - apiNotifs := []*apimodel.Notification{} + if len(notifs) == 0 { + return util.EmptyTimelineResponse(), nil + } + + timelineables := []timeline.Timelineable{} for _, n := range notifs { apiNotif, err := p.tc.NotificationToAPINotification(ctx, n) if err != nil { l.Debugf("got an error converting a notification to api, will skip it: %s", err) continue } - apiNotifs = append(apiNotifs, apiNotif) + timelineables = append(timelineables, apiNotif) } - return apiNotifs, nil + return util.PackageTimelineableResponse(util.TimelineableResponseParams{ + Items: timelineables, + Path: "api/v1/notifications", + NextMaxIDValue: timelineables[len(timelineables)-1].GetID(), + PrevMinIDKey: "since_id", + PrevMinIDValue: timelineables[0].GetID(), + Limit: limit, + }) } diff --git a/internal/processing/notification_test.go b/internal/processing/notification_test.go index 6f2d44c5c..6ecca92a9 100644 --- a/internal/processing/notification_test.go +++ b/internal/processing/notification_test.go @@ -23,6 +23,7 @@ "testing" "github.com/stretchr/testify/suite" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" ) type NotificationTestSuite struct { @@ -32,14 +33,19 @@ type NotificationTestSuite struct { // get a notification where someone has liked our status func (suite *NotificationTestSuite) TestGetNotifications() { receivingAccount := suite.testAccounts["local_account_1"] - notifs, err := suite.processor.NotificationsGet(context.Background(), suite.testAutheds["local_account_1"], 10, "", "") + notifsResponse, err := suite.processor.NotificationsGet(context.Background(), suite.testAutheds["local_account_1"], 10, "", "") suite.NoError(err) - suite.Len(notifs, 1) - notif := notifs[0] + suite.Len(notifsResponse.Items, 1) + notif, ok := notifsResponse.Items[0].(*apimodel.Notification) + if !ok { + panic("notif in response wasn't *apimodel.Notification") + } + suite.NotNil(notif.Status) suite.NotNil(notif.Status) suite.NotNil(notif.Status.Account) suite.Equal(receivingAccount.ID, notif.Status.Account.ID) + suite.Equal(`; rel="next", ; rel="prev"`, notifsResponse.LinkHeader) } func TestNotificationTestSuite(t *testing.T) { diff --git a/internal/processing/processor.go b/internal/processing/processor.go index 225606b70..a10e0d6b3 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -83,7 +83,7 @@ type Processor interface { AccountUpdate(ctx context.Context, authed *oauth.Auth, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, error) // AccountStatusesGet fetches a number of statuses (in time descending order) from the given account, filtered by visibility for // the account given in authed. - AccountStatusesGet(ctx context.Context, authed *oauth.Auth, targetAccountID string, limit int, excludeReplies bool, excludeReblogs bool, maxID string, minID string, pinned bool, mediaOnly bool, publicOnly bool) ([]apimodel.Status, gtserror.WithCode) + AccountStatusesGet(ctx context.Context, authed *oauth.Auth, targetAccountID string, limit int, excludeReplies bool, excludeReblogs bool, maxID string, minID string, pinned bool, mediaOnly bool, publicOnly bool) (*apimodel.TimelineResponse, gtserror.WithCode) // AccountFollowersGet fetches a list of the target account's followers. AccountFollowersGet(ctx context.Context, authed *oauth.Auth, targetAccountID string) ([]apimodel.Account, gtserror.WithCode) // AccountFollowingGet fetches a list of the accounts that target account is following. @@ -150,7 +150,7 @@ type Processor interface { MediaUpdate(ctx context.Context, authed *oauth.Auth, attachmentID string, form *apimodel.AttachmentUpdateRequest) (*apimodel.Attachment, gtserror.WithCode) // NotificationsGet - NotificationsGet(ctx context.Context, authed *oauth.Auth, limit int, maxID string, sinceID string) ([]*apimodel.Notification, gtserror.WithCode) + NotificationsGet(ctx context.Context, authed *oauth.Auth, limit int, maxID string, sinceID string) (*apimodel.TimelineResponse, gtserror.WithCode) // SearchGet performs a search with the given params, resolving/dereferencing remotely as desired SearchGet(ctx context.Context, authed *oauth.Auth, searchQuery *apimodel.SearchQuery) (*apimodel.SearchResult, gtserror.WithCode) @@ -177,11 +177,11 @@ type Processor interface { StatusGetContext(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Context, gtserror.WithCode) // HomeTimelineGet returns statuses from the home timeline, with the given filters/parameters. - HomeTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.StatusTimelineResponse, gtserror.WithCode) + HomeTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.TimelineResponse, gtserror.WithCode) // PublicTimelineGet returns statuses from the public/local timeline, with the given filters/parameters. - PublicTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.StatusTimelineResponse, gtserror.WithCode) + PublicTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.TimelineResponse, gtserror.WithCode) // FavedTimelineGet returns faved statuses, with the given filters/parameters. - FavedTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, minID string, limit int) (*apimodel.StatusTimelineResponse, gtserror.WithCode) + FavedTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, minID string, limit int) (*apimodel.TimelineResponse, gtserror.WithCode) // AuthorizeStreamingRequest returns a gotosocial account in exchange for an access token, or an error if the given token is not valid. AuthorizeStreamingRequest(ctx context.Context, accessToken string) (*gtsmodel.Account, error) diff --git a/internal/processing/statustimeline.go b/internal/processing/statustimeline.go index d4388f381..081709302 100644 --- a/internal/processing/statustimeline.go +++ b/internal/processing/statustimeline.go @@ -22,18 +22,17 @@ "context" "errors" "fmt" - "net/url" "github.com/sirupsen/logrus" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/timeline" "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/internal/util" "github.com/superseriousbusiness/gotosocial/internal/visibility" ) @@ -139,114 +138,100 @@ func StatusSkipInsertFunction() timeline.SkipInsertFunction { } } -func (p *processor) packageStatusResponse(statuses []*apimodel.Status, path string, nextMaxID string, prevMinID string, limit int) (*apimodel.StatusTimelineResponse, gtserror.WithCode) { - resp := &apimodel.StatusTimelineResponse{ - Statuses: []*apimodel.Status{}, - } - resp.Statuses = statuses - - // prepare the next and previous links - if len(statuses) != 0 { - protocol := config.GetProtocol() - host := config.GetHost() - - nextLink := &url.URL{ - Scheme: protocol, - Host: host, - Path: path, - RawQuery: fmt.Sprintf("limit=%d&max_id=%s", limit, nextMaxID), - } - next := fmt.Sprintf("<%s>; rel=\"next\"", nextLink.String()) - - prevLink := &url.URL{ - Scheme: protocol, - Host: host, - Path: path, - RawQuery: fmt.Sprintf("limit=%d&min_id=%s", limit, prevMinID), - } - prev := fmt.Sprintf("<%s>; rel=\"prev\"", prevLink.String()) - resp.LinkHeader = fmt.Sprintf("%s, %s", next, prev) - } - - return resp, nil -} - -func (p *processor) HomeTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.StatusTimelineResponse, gtserror.WithCode) { +func (p *processor) HomeTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.TimelineResponse, gtserror.WithCode) { preparedItems, err := p.statusTimelines.GetTimeline(ctx, authed.Account.ID, maxID, sinceID, minID, limit, local) if err != nil { return nil, gtserror.NewErrorInternalError(err) } if len(preparedItems) == 0 { - return &apimodel.StatusTimelineResponse{ - Statuses: []*apimodel.Status{}, - }, nil + return util.EmptyTimelineResponse(), nil } - statuses := []*apimodel.Status{} + timelineables := []timeline.Timelineable{} for _, i := range preparedItems { status, ok := i.(*apimodel.Status) if !ok { return nil, gtserror.NewErrorInternalError(errors.New("error converting prepared timeline entry to api status")) } - statuses = append(statuses, status) + timelineables = append(timelineables, status) } - return p.packageStatusResponse(statuses, "api/v1/timelines/home", statuses[len(preparedItems)-1].ID, statuses[0].ID, limit) + return util.PackageTimelineableResponse(util.TimelineableResponseParams{ + Items: timelineables, + Path: "api/v1/timelines/home", + NextMaxIDValue: timelineables[len(timelineables)-1].GetID(), + PrevMinIDValue: timelineables[0].GetID(), + Limit: limit, + }) } -func (p *processor) PublicTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.StatusTimelineResponse, gtserror.WithCode) { +func (p *processor) PublicTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.TimelineResponse, gtserror.WithCode) { statuses, err := p.db.GetPublicTimeline(ctx, authed.Account.ID, maxID, sinceID, minID, limit, local) if err != nil { if err == db.ErrNoEntries { // there are just no entries left - return &apimodel.StatusTimelineResponse{ - Statuses: []*apimodel.Status{}, - }, nil + return util.EmptyTimelineResponse(), nil } // there's an actual error return nil, gtserror.NewErrorInternalError(err) } - s, err := p.filterPublicStatuses(ctx, authed, statuses) + filtered, err := p.filterPublicStatuses(ctx, authed, statuses) if err != nil { return nil, gtserror.NewErrorInternalError(err) } - if len(s) == 0 { - return &apimodel.StatusTimelineResponse{ - Statuses: []*apimodel.Status{}, - }, nil + if len(filtered) == 0 { + return util.EmptyTimelineResponse(), nil } - return p.packageStatusResponse(s, "api/v1/timelines/public", s[len(s)-1].ID, s[0].ID, limit) + timelineables := []timeline.Timelineable{} + for _, i := range filtered { + timelineables = append(timelineables, i) + } + + return util.PackageTimelineableResponse(util.TimelineableResponseParams{ + Items: timelineables, + Path: "api/v1/timelines/public", + NextMaxIDValue: timelineables[len(timelineables)-1].GetID(), + PrevMinIDValue: timelineables[0].GetID(), + Limit: limit, + }) } -func (p *processor) FavedTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, minID string, limit int) (*apimodel.StatusTimelineResponse, gtserror.WithCode) { +func (p *processor) FavedTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, minID string, limit int) (*apimodel.TimelineResponse, gtserror.WithCode) { statuses, nextMaxID, prevMinID, err := p.db.GetFavedTimeline(ctx, authed.Account.ID, maxID, minID, limit) if err != nil { if err == db.ErrNoEntries { // there are just no entries left - return &apimodel.StatusTimelineResponse{ - Statuses: []*apimodel.Status{}, - }, nil + return util.EmptyTimelineResponse(), nil } // there's an actual error return nil, gtserror.NewErrorInternalError(err) } - s, err := p.filterFavedStatuses(ctx, authed, statuses) + filtered, err := p.filterFavedStatuses(ctx, authed, statuses) if err != nil { return nil, gtserror.NewErrorInternalError(err) } - if len(s) == 0 { - return &apimodel.StatusTimelineResponse{ - Statuses: []*apimodel.Status{}, - }, nil + if len(filtered) == 0 { + return util.EmptyTimelineResponse(), nil } - return p.packageStatusResponse(s, "api/v1/favourites", nextMaxID, prevMinID, limit) + timelineables := []timeline.Timelineable{} + for _, i := range filtered { + timelineables = append(timelineables, i) + } + + return util.PackageTimelineableResponse(util.TimelineableResponseParams{ + Items: timelineables, + Path: "api/v1/favourites", + NextMaxIDValue: nextMaxID, + PrevMinIDValue: prevMinID, + Limit: limit, + }) } func (p *processor) filterPublicStatuses(ctx context.Context, authed *oauth.Auth, statuses []*gtsmodel.Status) ([]*apimodel.Status, error) { diff --git a/internal/util/timeline.go b/internal/util/timeline.go new file mode 100644 index 000000000..929464add --- /dev/null +++ b/internal/util/timeline.go @@ -0,0 +1,104 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package util + +import ( + "fmt" + "net/url" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/timeline" +) + +// TimelineableResponseParams models the parameters to pass to PackageTimelineableResponse. +// +// The given items will be provided in the timeline response. +// +// The other values are all used to create the Link header so that callers know +// which endpoint to query next and previously in order to do paging. +type TimelineableResponseParams struct { + Items []timeline.Timelineable // Sorted slice of Timelineables (statuses, notifications, etc) + Path string // path to use for next/prev queries in the link header + NextMaxIDKey string // key to use for the next max id query param in the link header, defaults to 'max_id' + NextMaxIDValue string // value to use for next max id + PrevMinIDKey string // key to use for the prev min id query param in the link header, defaults to 'min_id' + PrevMinIDValue string // value to use for prev min id + Limit int // limit number of entries to return + ExtraQueryParams []string // any extra query parameters to provide in the link header, should be in the format 'example=value' +} + +// PackageTimelineableResponse is a convenience function for returning +// a bunch of timelineable items (notifications, statuses, etc), as well +// as a Link header to inform callers of where to find next/prev items. +func PackageTimelineableResponse(params TimelineableResponseParams) (*apimodel.TimelineResponse, gtserror.WithCode) { + if params.NextMaxIDKey == "" { + params.NextMaxIDKey = "max_id" + } + + if params.PrevMinIDKey == "" { + params.PrevMinIDKey = "min_id" + } + + timelineResponse := &apimodel.TimelineResponse{ + Items: params.Items, + } + + // prepare the next and previous links + if len(params.Items) != 0 { + protocol := config.GetProtocol() + host := config.GetHost() + + nextRaw := fmt.Sprintf("limit=%d&%s=%s", params.Limit, params.NextMaxIDKey, params.NextMaxIDValue) + for _, p := range params.ExtraQueryParams { + nextRaw = nextRaw + "&" + p + } + nextLink := &url.URL{ + Scheme: protocol, + Host: host, + Path: params.Path, + RawQuery: nextRaw, + } + next := fmt.Sprintf("<%s>; rel=\"next\"", nextLink.String()) + + prevRaw := fmt.Sprintf("limit=%d&%s=%s", params.Limit, params.PrevMinIDKey, params.PrevMinIDValue) + for _, p := range params.ExtraQueryParams { + prevRaw = prevRaw + "&" + p + } + prevLink := &url.URL{ + Scheme: protocol, + Host: host, + Path: params.Path, + RawQuery: prevRaw, + } + prev := fmt.Sprintf("<%s>; rel=\"prev\"", prevLink.String()) + timelineResponse.LinkHeader = next + ", " + prev + } + + return timelineResponse, nil +} + +// EmptyTimelineResponse just returns an empty +// TimelineResponse with no link header or items. +func EmptyTimelineResponse() *apimodel.TimelineResponse { + return &apimodel.TimelineResponse{ + Items: []timeline.Timelineable{}, + } +} diff --git a/internal/web/profile.go b/internal/web/profile.go index 66419dcc7..3155c022d 100644 --- a/internal/web/profile.go +++ b/internal/web/profile.go @@ -29,6 +29,7 @@ "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/ap" "github.com/superseriousbusiness/gotosocial/internal/api" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -79,7 +80,7 @@ func (m *Module) profileTemplateHandler(c *gin.Context) { // get latest 10 top-level public statuses; // ie., exclude replies and boosts, public only, // with or without media - statuses, errWithCode := m.processor.AccountStatusesGet(ctx, authed, account.ID, 10, true, true, "", "", false, false, true) + statusResp, errWithCode := m.processor.AccountStatusesGet(ctx, authed, account.ID, 10, true, true, "", "", false, false, true) if errWithCode != nil { l.Debugf("error getting statuses from processor: %s", errWithCode.Error()) c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) @@ -92,7 +93,11 @@ func (m *Module) profileTemplateHandler(c *gin.Context) { randomIndex := rand.Intn(len(m.defaultAvatars)) dummyAvatar := m.defaultAvatars[randomIndex] account.Avatar = dummyAvatar - for _, s := range statuses { + for _, i := range statusResp.Items { + s, ok := i.(*apimodel.Status) + if !ok { + panic("timelineable was not *apimodel.Status") + } s.Account.Avatar = dummyAvatar } } @@ -100,7 +105,7 @@ func (m *Module) profileTemplateHandler(c *gin.Context) { c.HTML(http.StatusOK, "profile.tmpl", gin.H{ "instance": instance, "account": account, - "statuses": statuses, + "statuses": statusResp.Items, "stylesheets": []string{ "/assets/Fork-Awesome/css/fork-awesome.min.css", "/assets/status.css",