diff --git a/PROGRESS.md b/PROGRESS.md index 20cc450ae..54e11d2b5 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -67,8 +67,8 @@ Things are moving on the project! As of July 2021 you can now: * [ ] /api/v1/accounts/search GET (Search for an account) * [ ] Bookmarks * [ ] /api/v1/bookmarks GET (See bookmarked statuses) - * [ ] Favourites - * [ ] /api/v1/favourites GET (See faved statuses) + * [x] Favourites + * [x] /api/v1/favourites GET (See faved statuses) * [ ] Mutes * [ ] /api/v1/mutes GET (See list of muted accounts) * [ ] Blocks diff --git a/internal/api/client/favourites/favourites.go b/internal/api/client/favourites/favourites.go new file mode 100644 index 000000000..e083f32f9 --- /dev/null +++ b/internal/api/client/favourites/favourites.go @@ -0,0 +1,67 @@ +/* + GoToSocial + Copyright (C) 2021 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 favourites + +import ( + "net/http" + + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/internal/router" +) + +const ( + // BasePath is the base URI path for serving favourites + BasePath = "/api/v1/favourites" + + // MaxIDKey is the url query for setting a max status ID to return + MaxIDKey = "max_id" + // SinceIDKey is the url query for returning results newer than the given ID + SinceIDKey = "since_id" + // MinIDKey is the url query for returning results immediately newer than the given ID + MinIDKey = "min_id" + // LimitKey is for specifying maximum number of results to return. + LimitKey = "limit" + // LocalKey is for specifying whether only local statuses should be returned + LocalKey = "local" +) + +// Module implements the ClientAPIModule interface for everything relating to viewing favourites +type Module struct { + config *config.Config + processor processing.Processor + log *logrus.Logger +} + +// New returns a new favourites module +func New(config *config.Config, processor processing.Processor, log *logrus.Logger) api.ClientModule { + return &Module{ + config: config, + processor: processor, + log: log, + } +} + +// Route attaches all routes from this module to the given router +func (m *Module) Route(r router.Router) error { + r.AttachHandler(http.MethodGet, BasePath, m.FavouritesGETHandler) + return nil +} diff --git a/internal/api/client/favourites/favouritesget.go b/internal/api/client/favourites/favouritesget.go new file mode 100644 index 000000000..76eb921e0 --- /dev/null +++ b/internal/api/client/favourites/favouritesget.go @@ -0,0 +1,57 @@ +package favourites + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// FavouritesGETHandler handles GETting favourites. +func (m *Module) FavouritesGETHandler(c *gin.Context) { + l := m.log.WithField("func", "PublicTimelineGETHandler") + + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + l.Debugf("error authing: %s", err) + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + maxID := "" + maxIDString := c.Query(MaxIDKey) + if maxIDString != "" { + maxID = maxIDString + } + + minID := "" + minIDString := c.Query(MinIDKey) + if minIDString != "" { + minID = minIDString + } + + limit := 20 + limitString := c.Query(LimitKey) + if limitString != "" { + i, err := strconv.ParseInt(limitString, 10, 64) + if err != nil { + l.Debugf("error parsing limit string: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse limit query param"}) + return + } + limit = int(i) + } + + resp, errWithCode := m.processor.FavedTimelineGet(authed, maxID, minID, limit) + if errWithCode != nil { + l.Debugf("error from processor FavedTimelineGet: %s", errWithCode) + c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + return + } + + if resp.LinkHeader != "" { + c.Header("Link", resp.LinkHeader) + } + c.JSON(http.StatusOK, resp.Statuses) +} diff --git a/internal/api/client/timeline/home.go b/internal/api/client/timeline/home.go index 86606a0dd..cb72895f9 100644 --- a/internal/api/client/timeline/home.go +++ b/internal/api/client/timeline/home.go @@ -94,6 +94,8 @@ func (m *Module) HomeTimelineGETHandler(c *gin.Context) { return } - c.Header("Link", resp.LinkHeader) + if resp.LinkHeader != "" { + c.Header("Link", resp.LinkHeader) + } c.JSON(http.StatusOK, resp.Statuses) } diff --git a/internal/api/client/timeline/public.go b/internal/api/client/timeline/public.go index f4b233064..6898d781b 100644 --- a/internal/api/client/timeline/public.go +++ b/internal/api/client/timeline/public.go @@ -81,12 +81,15 @@ func (m *Module) PublicTimelineGETHandler(c *gin.Context) { local = i } - statuses, errWithCode := m.processor.PublicTimelineGet(authed, maxID, sinceID, minID, limit, local) + resp, errWithCode := m.processor.PublicTimelineGet(authed, maxID, sinceID, minID, limit, local) if errWithCode != nil { - l.Debugf("error from processor account statuses get: %s", errWithCode) + l.Debugf("error from processor PublicTimelineGet: %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.Statuses) } diff --git a/internal/cliactions/server/server.go b/internal/cliactions/server/server.go index dfe05f47a..b8eb2e381 100644 --- a/internal/cliactions/server/server.go +++ b/internal/cliactions/server/server.go @@ -15,6 +15,7 @@ "github.com/superseriousbusiness/gotosocial/internal/api/client/app" "github.com/superseriousbusiness/gotosocial/internal/api/client/auth" "github.com/superseriousbusiness/gotosocial/internal/api/client/emoji" + "github.com/superseriousbusiness/gotosocial/internal/api/client/favourites" "github.com/superseriousbusiness/gotosocial/internal/api/client/fileserver" "github.com/superseriousbusiness/gotosocial/internal/api/client/filter" "github.com/superseriousbusiness/gotosocial/internal/api/client/followrequest" @@ -141,6 +142,7 @@ statusModule := status.New(c, processor, log) securityModule := security.New(c, dbService, log) streamingModule := streaming.New(c, processor, log) + favouritesModule := favourites.New(c, processor, log) apis := []api.ClientModule{ // modules with middleware go first @@ -167,6 +169,7 @@ emojiModule, listsModule, streamingModule, + favouritesModule, } for _, m := range apis { diff --git a/internal/cliactions/testrig/testrig.go b/internal/cliactions/testrig/testrig.go index 43d2db726..312d19a62 100644 --- a/internal/cliactions/testrig/testrig.go +++ b/internal/cliactions/testrig/testrig.go @@ -17,6 +17,7 @@ "github.com/superseriousbusiness/gotosocial/internal/api/client/app" "github.com/superseriousbusiness/gotosocial/internal/api/client/auth" "github.com/superseriousbusiness/gotosocial/internal/api/client/emoji" + "github.com/superseriousbusiness/gotosocial/internal/api/client/favourites" "github.com/superseriousbusiness/gotosocial/internal/api/client/fileserver" "github.com/superseriousbusiness/gotosocial/internal/api/client/filter" "github.com/superseriousbusiness/gotosocial/internal/api/client/followrequest" @@ -86,6 +87,7 @@ statusModule := status.New(c, processor, log) securityModule := security.New(c, dbService, log) streamingModule := streaming.New(c, processor, log) + favouritesModule := favourites.New(c, processor, log) apis := []api.ClientModule{ // modules with middleware go first @@ -112,6 +114,7 @@ emojiModule, listsModule, streamingModule, + favouritesModule, } for _, m := range apis { diff --git a/internal/db/db.go b/internal/db/db.go index 1d7ed8b58..0a3979df6 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -241,13 +241,26 @@ type DB interface { // This slice will be unfiltered, not taking account of blocks and whatnot, so filter it before serving it back to a user. WhoBoostedStatus(status *gtsmodel.Status) ([]*gtsmodel.Account, error) - // GetStatusesWhereFollowing returns a slice of statuses from accounts that are followed by the given account id. - GetStatusesWhereFollowing(accountID string, maxID string, sinceID string, minID string, limit int, local bool) ([]*gtsmodel.Status, error) + // GetHomeTimelineForAccount returns a slice of statuses from accounts that are followed by the given account id. + // + // Statuses should be returned in descending order of when they were created (newest first). + GetHomeTimelineForAccount(accountID string, maxID string, sinceID string, minID string, limit int, local bool) ([]*gtsmodel.Status, error) - // GetPublicTimelineForAccount fetches the account's PUBLIC timline -- ie., posts and replies that are public. + // GetPublicTimelineForAccount fetches the account's PUBLIC timeline -- ie., posts and replies that are public. // It will use the given filters and try to return as many statuses as possible up to the limit. + // + // Statuses should be returned in descending order of when they were created (newest first). GetPublicTimelineForAccount(accountID string, maxID string, sinceID string, minID string, limit int, local bool) ([]*gtsmodel.Status, error) + // GetFavedTimelineForAccount fetches the account's FAVED timeline -- ie., posts and replies that the requesting account has faved. + // It will use the given filters and try to return as many statuses as possible up to the limit. + // + // Note that unlike the other GetTimeline functions, the returned statuses will be arranged by their FAVE id, not the STATUS id. + // In other words, they'll be returned in descending order of when they were faved by the requesting user, not when they were created. + // + // Also note the extra return values, which correspond to the nextMaxID and prevMinID for building Link headers. + GetFavedTimelineForAccount(accountID string, maxID string, minID string, limit int) ([]*gtsmodel.Status, string, string, error) + // GetNotificationsForAccount returns a list of notifications that pertain to the given accountID. GetNotificationsForAccount(accountID string, limit int, maxID string, sinceID string) ([]*gtsmodel.Notification, error) diff --git a/internal/db/pg/pg.go b/internal/db/pg/pg.go index 614968e22..ad75cef15 100644 --- a/internal/db/pg/pg.go +++ b/internal/db/pg/pg.go @@ -814,92 +814,6 @@ func (ps *postgresService) WhoBoostedStatus(status *gtsmodel.Status) ([]*gtsmode return accounts, nil } -func (ps *postgresService) GetStatusesWhereFollowing(accountID string, maxID string, sinceID string, minID string, limit int, local bool) ([]*gtsmodel.Status, error) { - statuses := []*gtsmodel.Status{} - - q := ps.conn.Model(&statuses) - - q = q.ColumnExpr("status.*"). - Join("JOIN follows AS f ON f.target_account_id = status.account_id"). - Where("f.account_id = ?", accountID). - Order("status.id DESC") - - if maxID != "" { - q = q.Where("status.id < ?", maxID) - } - - if sinceID != "" { - q = q.Where("status.id > ?", sinceID) - } - - if minID != "" { - q = q.Where("status.id > ?", minID) - } - - if local { - q = q.Where("status.local = ?", local) - } - - if limit > 0 { - q = q.Limit(limit) - } - - err := q.Select() - if err != nil { - if err == pg.ErrNoRows { - return nil, db.ErrNoEntries{} - } - return nil, err - } - - if len(statuses) == 0 { - return nil, db.ErrNoEntries{} - } - - return statuses, nil -} - -func (ps *postgresService) GetPublicTimelineForAccount(accountID string, maxID string, sinceID string, minID string, limit int, local bool) ([]*gtsmodel.Status, error) { - statuses := []*gtsmodel.Status{} - - q := ps.conn.Model(&statuses). - Where("visibility = ?", gtsmodel.VisibilityPublic). - Where("? IS NULL", pg.Ident("in_reply_to_id")). - Where("? IS NULL", pg.Ident("in_reply_to_uri")). - Where("? IS NULL", pg.Ident("boost_of_id")). - Order("status.id DESC") - - if maxID != "" { - q = q.Where("status.id < ?", maxID) - } - - if sinceID != "" { - q = q.Where("status.id > ?", sinceID) - } - - if minID != "" { - q = q.Where("status.id > ?", minID) - } - - if local { - q = q.Where("status.local = ?", local) - } - - if limit > 0 { - q = q.Limit(limit) - } - - err := q.Select() - if err != nil { - if err == pg.ErrNoRows { - return nil, db.ErrNoEntries{} - } - return nil, err - } - - return statuses, nil -} - func (ps *postgresService) GetNotificationsForAccount(accountID string, limit int, maxID string, sinceID string) ([]*gtsmodel.Notification, error) { notifications := []*gtsmodel.Notification{} diff --git a/internal/db/pg/timeline.go b/internal/db/pg/timeline.go new file mode 100644 index 000000000..95acd4f38 --- /dev/null +++ b/internal/db/pg/timeline.go @@ -0,0 +1,185 @@ +/* + GoToSocial + Copyright (C) 2021 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 pg + +import ( + "sort" + + "github.com/go-pg/pg/v10" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (ps *postgresService) GetHomeTimelineForAccount(accountID string, maxID string, sinceID string, minID string, limit int, local bool) ([]*gtsmodel.Status, error) { + statuses := []*gtsmodel.Status{} + + q := ps.conn.Model(&statuses) + + q = q.ColumnExpr("status.*"). + Join("LEFT JOIN follows AS f ON f.target_account_id = status.account_id"). + Where("f.account_id = ?", accountID). + Order("status.id DESC") + + if maxID != "" { + q = q.Where("status.id < ?", maxID) + } + + if sinceID != "" { + q = q.Where("status.id > ?", sinceID) + } + + if minID != "" { + q = q.Where("status.id > ?", minID) + } + + if local { + q = q.Where("status.local = ?", local) + } + + if limit > 0 { + q = q.Limit(limit) + } + + err := q.Select() + if err != nil { + if err == pg.ErrNoRows { + return nil, db.ErrNoEntries{} + } + return nil, err + } + + if len(statuses) == 0 { + return nil, db.ErrNoEntries{} + } + + return statuses, nil +} + +func (ps *postgresService) GetPublicTimelineForAccount(accountID string, maxID string, sinceID string, minID string, limit int, local bool) ([]*gtsmodel.Status, error) { + statuses := []*gtsmodel.Status{} + + q := ps.conn.Model(&statuses). + Where("visibility = ?", gtsmodel.VisibilityPublic). + Where("? IS NULL", pg.Ident("in_reply_to_id")). + Where("? IS NULL", pg.Ident("in_reply_to_uri")). + Where("? IS NULL", pg.Ident("boost_of_id")). + Order("status.id DESC") + + if maxID != "" { + q = q.Where("status.id < ?", maxID) + } + + if sinceID != "" { + q = q.Where("status.id > ?", sinceID) + } + + if minID != "" { + q = q.Where("status.id > ?", minID) + } + + if local { + q = q.Where("status.local = ?", local) + } + + if limit > 0 { + q = q.Limit(limit) + } + + err := q.Select() + if err != nil { + if err == pg.ErrNoRows { + return nil, db.ErrNoEntries{} + } + return nil, err + } + + if len(statuses) == 0 { + return nil, db.ErrNoEntries{} + } + + return statuses, nil +} + +// TODO optimize this query and the logic here, because it's slow as balls -- it takes like a literal second to return with a limit of 20! +// It might be worth serving it through a timeline instead of raw DB queries, like we do for Home feeds. +func (ps *postgresService) GetFavedTimelineForAccount(accountID string, maxID string, minID string, limit int) ([]*gtsmodel.Status, string, string, error) { + + faves := []*gtsmodel.StatusFave{} + + fq := ps.conn.Model(&faves). + Where("account_id = ?", accountID). + Order("id DESC") + + if maxID != "" { + fq = fq.Where("id < ?", maxID) + } + + if minID != "" { + fq = fq.Where("id > ?", minID) + } + + if limit > 0 { + fq = fq.Limit(limit) + } + + err := fq.Select() + if err != nil { + if err == pg.ErrNoRows { + return nil, "", "", db.ErrNoEntries{} + } + return nil, "", "", err + } + + if len(faves) == 0 { + return nil, "", "", db.ErrNoEntries{} + } + + // map[statusID]faveID -- we need this to sort statuses by fave ID rather than their own ID + statusesFavesMap := map[string]string{} + + in := []string{} + for _, f := range faves { + statusesFavesMap[f.StatusID] = f.ID + in = append(in, f.StatusID) + } + + statuses := []*gtsmodel.Status{} + err = ps.conn.Model(&statuses).Where("id IN (?)", pg.In(in)).Select() + if err != nil { + if err == pg.ErrNoRows { + return nil, "", "", db.ErrNoEntries{} + } + return nil, "", "", err + } + + if len(statuses) == 0 { + return nil, "", "", db.ErrNoEntries{} + } + + // arrange statuses by fave ID + sort.Slice(statuses, func(i int, j int) bool { + statusI := statuses[i] + statusJ := statuses[j] + return statusesFavesMap[statusI.ID] < statusesFavesMap[statusJ.ID] + }) + + nextMaxID := faves[len(faves)-1].ID + prevMinID := faves[0].ID + return statuses, nextMaxID, prevMinID, nil +} diff --git a/internal/processing/processor.go b/internal/processing/processor.go index 302368411..bb4cd2da7 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -151,7 +151,9 @@ type Processor interface { // HomeTimelineGet returns statuses from the home timeline, with the given filters/parameters. HomeTimelineGet(authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.StatusTimelineResponse, gtserror.WithCode) // PublicTimelineGet returns statuses from the public/local timeline, with the given filters/parameters. - PublicTimelineGet(authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) ([]*apimodel.Status, gtserror.WithCode) + PublicTimelineGet(authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.StatusTimelineResponse, gtserror.WithCode) + // FavedTimelineGet returns faved statuses, with the given filters/parameters. + FavedTimelineGet(authed *oauth.Auth, maxID string, minID string, limit int) (*apimodel.StatusTimelineResponse, gtserror.WithCode) // AuthorizeStreamingRequest returns a gotosocial account in exchange for an access token, or an error if the given token is not valid. AuthorizeStreamingRequest(accessToken string) (*gtsmodel.Account, error) diff --git a/internal/processing/status/fave.go b/internal/processing/status/fave.go index 23f0d2944..0dfee6233 100644 --- a/internal/processing/status/fave.go +++ b/internal/processing/status/fave.go @@ -60,7 +60,7 @@ func (p *processor) Fave(account *gtsmodel.Account, targetStatusID string) (*api } if newFave { - thisFaveID, err := id.NewRandomULID() + thisFaveID, err := id.NewULID() if err != nil { return nil, gtserror.NewErrorInternalError(err) } diff --git a/internal/processing/timeline.go b/internal/processing/timeline.go index 8f6b1d26b..b5cbf433a 100644 --- a/internal/processing/timeline.go +++ b/internal/processing/timeline.go @@ -31,33 +31,27 @@ "github.com/superseriousbusiness/gotosocial/internal/oauth" ) -func (p *processor) HomeTimelineGet(authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.StatusTimelineResponse, gtserror.WithCode) { +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{}, } - - apiStatuses, err := p.timelineManager.HomeTimeline(authed.Account.ID, maxID, sinceID, minID, limit, local) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - resp.Statuses = apiStatuses + resp.Statuses = statuses // prepare the next and previous links - if len(apiStatuses) != 0 { + if len(statuses) != 0 { nextLink := &url.URL{ Scheme: p.config.Protocol, Host: p.config.Host, - Path: "/api/v1/timelines/home", - RawPath: url.PathEscape("api/v1/timelines/home"), - RawQuery: fmt.Sprintf("limit=%d&max_id=%s", limit, apiStatuses[len(apiStatuses)-1].ID), + Path: path, + RawQuery: fmt.Sprintf("limit=%d&max_id=%s", limit, nextMaxID), } next := fmt.Sprintf("<%s>; rel=\"next\"", nextLink.String()) prevLink := &url.URL{ Scheme: p.config.Protocol, Host: p.config.Host, - Path: "/api/v1/timelines/home", - RawQuery: fmt.Sprintf("limit=%d&min_id=%s", limit, apiStatuses[0].ID), + 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) @@ -66,37 +60,81 @@ func (p *processor) HomeTimelineGet(authed *oauth.Auth, maxID string, sinceID st return resp, nil } -func (p *processor) PublicTimelineGet(authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) ([]*apimodel.Status, gtserror.WithCode) { - statuses, err := p.db.GetPublicTimelineForAccount(authed.Account.ID, maxID, sinceID, minID, limit, local) +func (p *processor) HomeTimelineGet(authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.StatusTimelineResponse, gtserror.WithCode) { + statuses, err := p.timelineManager.HomeTimeline(authed.Account.ID, maxID, sinceID, minID, limit, local) if err != nil { return nil, gtserror.NewErrorInternalError(err) } - s, err := p.filterStatuses(authed, statuses) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) + if len(statuses) == 0 { + return &apimodel.StatusTimelineResponse{ + Statuses: []*apimodel.Status{}, + }, nil } - return s, nil + return p.packageStatusResponse(statuses, "api/v1/timelines/home", statuses[len(statuses)-1].ID, statuses[0].ID, limit) } -func (p *processor) filterStatuses(authed *oauth.Auth, statuses []*gtsmodel.Status) ([]*apimodel.Status, error) { - l := p.log.WithField("func", "filterStatuses") +func (p *processor) PublicTimelineGet(authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.StatusTimelineResponse, gtserror.WithCode) { + statuses, err := p.db.GetPublicTimelineForAccount(authed.Account.ID, maxID, sinceID, minID, limit, local) + if err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + // there are just no entries left + return &apimodel.StatusTimelineResponse{ + Statuses: []*apimodel.Status{}, + }, nil + } + // there's an actual error + return nil, gtserror.NewErrorInternalError(err) + } + + s, err := p.filterPublicStatuses(authed, statuses) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + return p.packageStatusResponse(s, "api/v1/timelines/public", s[len(s)-1].ID, s[0].ID, limit) +} + +func (p *processor) FavedTimelineGet(authed *oauth.Auth, maxID string, minID string, limit int) (*apimodel.StatusTimelineResponse, gtserror.WithCode) { + statuses, nextMaxID, prevMinID, err := p.db.GetFavedTimelineForAccount(authed.Account.ID, maxID, minID, limit) + if err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + // there are just no entries left + return &apimodel.StatusTimelineResponse{ + Statuses: []*apimodel.Status{}, + }, nil + } + // there's an actual error + return nil, gtserror.NewErrorInternalError(err) + } + + s, err := p.filterFavedStatuses(authed, statuses) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + return p.packageStatusResponse(s, "api/v1/favourites", nextMaxID, prevMinID, limit) +} + +func (p *processor) filterPublicStatuses(authed *oauth.Auth, statuses []*gtsmodel.Status) ([]*apimodel.Status, error) { + l := p.log.WithField("func", "filterPublicStatuses") apiStatuses := []*apimodel.Status{} for _, s := range statuses { targetAccount := >smodel.Account{} if err := p.db.GetByID(s.AccountID, targetAccount); err != nil { if _, ok := err.(db.ErrNoEntries); ok { - l.Debugf("skipping status %s because account %s can't be found in the db", s.ID, s.AccountID) + l.Debugf("filterPublicStatuses: skipping status %s because account %s can't be found in the db", s.ID, s.AccountID) continue } - return nil, gtserror.NewErrorInternalError(fmt.Errorf("HomeTimelineGet: error getting status author: %s", err)) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("filterPublicStatuses: error getting status author: %s", err)) } - timelineable, err := p.filter.StatusHometimelineable(s, authed.Account) + timelineable, err := p.filter.StatusPublictimelineable(s, authed.Account) if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("HomeTimelineGet: error checking status visibility: %s", err)) + l.Debugf("filterPublicStatuses: skipping status %s because of an error checking status visibility: %s", s.ID, err) + continue } if !timelineable { continue @@ -104,7 +142,42 @@ func (p *processor) filterStatuses(authed *oauth.Auth, statuses []*gtsmodel.Stat apiStatus, err := p.tc.StatusToMasto(s, authed.Account) if err != nil { - l.Debugf("skipping status %s because it couldn't be converted to its mastodon representation: %s", s.ID, err) + l.Debugf("filterPublicStatuses: skipping status %s because it couldn't be converted to its mastodon representation: %s", s.ID, err) + continue + } + + apiStatuses = append(apiStatuses, apiStatus) + } + + return apiStatuses, nil +} + +func (p *processor) filterFavedStatuses(authed *oauth.Auth, statuses []*gtsmodel.Status) ([]*apimodel.Status, error) { + l := p.log.WithField("func", "filterFavedStatuses") + + apiStatuses := []*apimodel.Status{} + for _, s := range statuses { + targetAccount := >smodel.Account{} + if err := p.db.GetByID(s.AccountID, targetAccount); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + l.Debugf("filterFavedStatuses: skipping status %s because account %s can't be found in the db", s.ID, s.AccountID) + continue + } + return nil, gtserror.NewErrorInternalError(fmt.Errorf("filterPublicStatuses: error getting status author: %s", err)) + } + + timelineable, err := p.filter.StatusVisible(s, authed.Account) + if err != nil { + l.Debugf("filterFavedStatuses: skipping status %s because of an error checking status visibility: %s", s.ID, err) + continue + } + if !timelineable { + continue + } + + apiStatus, err := p.tc.StatusToMasto(s, authed.Account) + if err != nil { + l.Debugf("filterFavedStatuses: skipping status %s because it couldn't be converted to its mastodon representation: %s", s.ID, err) continue } @@ -157,7 +230,7 @@ func (p *processor) initTimelineFor(account *gtsmodel.Account, wg *sync.WaitGrou desiredIndexLength := p.timelineManager.GetDesiredIndexLength() - statuses, err := p.db.GetStatusesWhereFollowing(account.ID, "", "", "", desiredIndexLength, false) + statuses, err := p.db.GetHomeTimelineForAccount(account.ID, "", "", "", desiredIndexLength, false) if err != nil { if _, ok := err.(db.ErrNoEntries); !ok { l.Error(fmt.Errorf("initTimelineFor: error getting statuses: %s", err)) @@ -176,7 +249,7 @@ func (p *processor) initTimelineFor(account *gtsmodel.Account, wg *sync.WaitGrou } if rearmostStatusID != "" { - moreStatuses, err := p.db.GetStatusesWhereFollowing(account.ID, rearmostStatusID, "", "", desiredIndexLength/2, false) + moreStatuses, err := p.db.GetHomeTimelineForAccount(account.ID, rearmostStatusID, "", "", desiredIndexLength/2, false) if err != nil { l.Error(fmt.Errorf("initTimelineFor: error getting more statuses: %s", err)) return diff --git a/internal/timeline/index.go b/internal/timeline/index.go index 261722b74..8c6b0d578 100644 --- a/internal/timeline/index.go +++ b/internal/timeline/index.go @@ -23,7 +23,7 @@ func (t *timeline) IndexBefore(statusID string, include bool, amount int) error grabloop: for len(filtered) < amount { - statuses, err := t.db.GetStatusesWhereFollowing(t.accountID, "", offsetStatus, "", amount, false) + statuses, err := t.db.GetHomeTimelineForAccount(t.accountID, "", offsetStatus, "", amount, false) if err != nil { if _, ok := err.(db.ErrNoEntries); ok { break grabloop // we just don't have enough statuses left in the db so index what we've got and then bail @@ -58,7 +58,7 @@ func (t *timeline) IndexBehind(statusID string, amount int) error { grabloop: for len(filtered) < amount { - statuses, err := t.db.GetStatusesWhereFollowing(t.accountID, offsetStatus, "", "", amount, false) + statuses, err := t.db.GetHomeTimelineForAccount(t.accountID, offsetStatus, "", "", amount, false) if err != nil { if _, ok := err.(db.ErrNoEntries); ok { break grabloop // we just don't have enough statuses left in the db so index what we've got and then bail diff --git a/internal/visibility/filter.go b/internal/visibility/filter.go index d12ad0ff6..181eb8ee7 100644 --- a/internal/visibility/filter.go +++ b/internal/visibility/filter.go @@ -17,6 +17,11 @@ type Filter interface { // // This function will call StatusVisible internally, so it's not necessary to call it beforehand. StatusHometimelineable(targetStatus *gtsmodel.Status, requestingAccount *gtsmodel.Account) (bool, error) + + // StatusPublictimelineable returns true if targetStatus should be in the public timeline of the requesting account. + // + // This function will call StatusVisible internally, so it's not necessary to call it beforehand. + StatusPublictimelineable(targetStatus *gtsmodel.Status, timelineOwnerAccount *gtsmodel.Account) (bool, error) } type filter struct { diff --git a/internal/visibility/statuspublictimelineable.go b/internal/visibility/statuspublictimelineable.go new file mode 100644 index 000000000..d7f68faee --- /dev/null +++ b/internal/visibility/statuspublictimelineable.go @@ -0,0 +1,37 @@ +package visibility + +import ( + "fmt" + + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (f *filter) StatusPublictimelineable(targetStatus *gtsmodel.Status, timelineOwnerAccount *gtsmodel.Account) (bool, error) { + l := f.log.WithFields(logrus.Fields{ + "func": "StatusPublictimelineable", + "statusID": targetStatus.ID, + }) + + // Don't timeline a reply + if targetStatus.InReplyToURI != "" || targetStatus.InReplyToID != "" || targetStatus.InReplyToAccountID != "" { + return false, nil + } + + // status owner should always be able to see their own status in their timeline so we can return early if this is the case + if timelineOwnerAccount != nil && targetStatus.AccountID == timelineOwnerAccount.ID { + return true, nil + } + + v, err := f.StatusVisible(targetStatus, timelineOwnerAccount) + if err != nil { + return false, fmt.Errorf("StatusPublictimelineable: error checking visibility of status with id %s: %s", targetStatus.ID, err) + } + + if !v { + l.Debug("status is not publicTimelineable because it's not visible to the requester") + return false, nil + } + + return true, nil +}