Home timeline (#28)

* v. basic implementation of home timeline

* Go fmt ./...
This commit is contained in:
Tobi Smethurst 2021-05-21 23:04:59 +02:00 committed by GitHub
parent d839f27c30
commit 0df2e18cc0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 317 additions and 52 deletions

View File

@ -69,7 +69,7 @@ func (m *Module) OauthTokenMiddleware(c *gin.Context) {
if cid := ti.GetClientID(); cid != "" { if cid := ti.GetClientID(); cid != "" {
l.Tracef("authenticated client %s with bearer token, scope is %s", cid, ti.GetScope()) l.Tracef("authenticated client %s with bearer token, scope is %s", cid, ti.GetScope())
app := &gtsmodel.Application{} app := &gtsmodel.Application{}
if err := m.db.GetWhere([]db.Where{{Key: "client_id",Value: cid}}, app); err != nil { if err := m.db.GetWhere([]db.Where{{Key: "client_id", Value: cid}}, app); err != nil {
l.Tracef("no app found for client %s", cid) l.Tracef("no app found for client %s", cid)
} }
c.Set(oauth.SessionAuthorizedApplication, app) c.Set(oauth.SessionAuthorizedApplication, app)

View File

@ -0,0 +1,98 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
package timeline
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
// HomeTimelineGETHandler serves status from the HOME timeline.
//
// Several different filters might be passed into this function in the query:
//
// max_id -- the maximum ID of the status to show
// since_id -- Return results newer than id
// min_id -- Return results immediately newer than id
// limit -- show only limit number of statuses
// local -- Return only local statuses?
func (m *Module) HomeTimelineGETHandler(c *gin.Context) {
l := m.log.WithField("func", "AccountStatusesGETHandler")
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
}
sinceID := ""
sinceIDString := c.Query(SinceIDKey)
if sinceIDString != "" {
sinceID = sinceIDString
}
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)
}
local := false
localString := c.Query(LocalKey)
if localString != "" {
i, err := strconv.ParseBool(localString)
if err != nil {
l.Debugf("error parsing local string: %s", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse local query param"})
return
}
local = i
}
statuses, errWithCode := m.processor.HomeTimelineGet(authed, maxID, sinceID, minID, limit, local)
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)
}

View File

@ -0,0 +1,68 @@
package timeline
/*
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 <http://www.gnu.org/licenses/>.
*/
import (
"net/http"
"github.com/sirupsen/logrus"
"github.com/superseriousbusiness/gotosocial/internal/api"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/message"
"github.com/superseriousbusiness/gotosocial/internal/router"
)
const (
// BasePath is the base URI path for serving timelines
BasePath = "/api/v1/timelines"
// HomeTimeline is the path for the home timeline
HomeTimeline = BasePath + "/home"
// 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"
// Limit key 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 timelines
type Module struct {
config *config.Config
processor message.Processor
log *logrus.Logger
}
// New returns a new timeline module
func New(config *config.Config, processor message.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, HomeTimeline, m.HomeTimelineGETHandler)
return nil
}

View File

@ -32,18 +32,20 @@ const (
// ErrNoEntries is to be returned from the DB interface when no entries are found for a given query. // ErrNoEntries is to be returned from the DB interface when no entries are found for a given query.
type ErrNoEntries struct{} type ErrNoEntries struct{}
func (e ErrNoEntries) Error() string { func (e ErrNoEntries) Error() string {
return "no entries" return "no entries"
} }
// ErrAlreadyExists is to be returned from the DB interface when an entry already exists for a given query or its constraints. // ErrAlreadyExists is to be returned from the DB interface when an entry already exists for a given query or its constraints.
type ErrAlreadyExists struct{} type ErrAlreadyExists struct{}
func (e ErrAlreadyExists) Error() string { func (e ErrAlreadyExists) Error() string {
return "already exists" return "already exists"
} }
type Where struct { type Where struct {
Key string Key string
Value interface{} Value interface{}
} }
@ -278,6 +280,10 @@ 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. // This slice will be unfiltered, not taking account of blocks and whatnot, so filter it before serving it back to a user.
WhoFavedStatus(status *gtsmodel.Status) ([]*gtsmodel.Account, error) WhoFavedStatus(status *gtsmodel.Status) ([]*gtsmodel.Account, error)
// GetHomeTimelineForAccount fetches the account's HOME timeline -- ie., posts and replies from people they *follow*.
// It will use the given filters and try to return as many statuses up to the limit as possible.
GetHomeTimelineForAccount(accountID string, maxID string, sinceID string, minID string, limit int, local bool) ([]*gtsmodel.Status, error)
/* /*
USEFUL CONVERSION FUNCTIONS USEFUL CONVERSION FUNCTIONS
*/ */

View File

@ -1103,6 +1103,26 @@ func (ps *postgresService) WhoFavedStatus(status *gtsmodel.Status) ([]*gtsmodel.
return accounts, nil return accounts, nil
} }
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).
ColumnExpr("status.*").
Join("JOIN follows AS f ON f.target_account_id = status.account_id").
Where("f.account_id = ?", accountID).
Limit(limit).
Order("status.created_at DESC")
err := q.Select()
if err != nil {
if err != pg.ErrNoRows {
return nil, err
}
}
return statuses, nil
}
/* /*
CONVERSION FUNCTIONS CONVERSION FUNCTIONS
*/ */

View File

@ -38,6 +38,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/api/client/instance" "github.com/superseriousbusiness/gotosocial/internal/api/client/instance"
mediaModule "github.com/superseriousbusiness/gotosocial/internal/api/client/media" mediaModule "github.com/superseriousbusiness/gotosocial/internal/api/client/media"
"github.com/superseriousbusiness/gotosocial/internal/api/client/status" "github.com/superseriousbusiness/gotosocial/internal/api/client/status"
"github.com/superseriousbusiness/gotosocial/internal/api/client/timeline"
"github.com/superseriousbusiness/gotosocial/internal/api/s2s/user" "github.com/superseriousbusiness/gotosocial/internal/api/s2s/user"
"github.com/superseriousbusiness/gotosocial/internal/api/s2s/webfinger" "github.com/superseriousbusiness/gotosocial/internal/api/s2s/webfinger"
"github.com/superseriousbusiness/gotosocial/internal/api/security" "github.com/superseriousbusiness/gotosocial/internal/api/security"
@ -116,6 +117,7 @@ var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logr
followRequestsModule := followrequest.New(c, processor, log) followRequestsModule := followrequest.New(c, processor, log)
webfingerModule := webfinger.New(c, processor, log) webfingerModule := webfinger.New(c, processor, log)
usersModule := user.New(c, processor, log) usersModule := user.New(c, processor, log)
timelineModule := timeline.New(c, processor, log)
mm := mediaModule.New(c, processor, log) mm := mediaModule.New(c, processor, log)
fileServerModule := fileserver.New(c, processor, log) fileServerModule := fileserver.New(c, processor, log)
adminModule := admin.New(c, processor, log) adminModule := admin.New(c, processor, log)
@ -138,6 +140,7 @@ var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logr
statusModule, statusModule,
webfingerModule, webfingerModule,
usersModule, usersModule,
timelineModule,
} }
for _, m := range apis { for _, m := range apis {

View File

@ -164,46 +164,46 @@ func (p *processor) GetFediFollowers(requestedUsername string, request *http.Req
} }
func (p *processor) GetFediStatus(requestedUsername string, requestedStatusID string, request *http.Request) (interface{}, ErrorWithCode) { func (p *processor) GetFediStatus(requestedUsername string, requestedStatusID string, request *http.Request) (interface{}, ErrorWithCode) {
// get the account the request is referring to // get the account the request is referring to
requestedAccount := &gtsmodel.Account{} requestedAccount := &gtsmodel.Account{}
if err := p.db.GetLocalAccountByUsername(requestedUsername, requestedAccount); err != nil { if err := p.db.GetLocalAccountByUsername(requestedUsername, requestedAccount); err != nil {
return nil, NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err)) return nil, NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
} }
// authenticate the request // authenticate the request
requestingAccount, err := p.authenticateAndDereferenceFediRequest(requestedUsername, request) requestingAccount, err := p.authenticateAndDereferenceFediRequest(requestedUsername, request)
if err != nil { if err != nil {
return nil, NewErrorNotAuthorized(err) return nil, NewErrorNotAuthorized(err)
} }
blocked, err := p.db.Blocked(requestedAccount.ID, requestingAccount.ID) blocked, err := p.db.Blocked(requestedAccount.ID, requestingAccount.ID)
if err != nil { if err != nil {
return nil, NewErrorInternalError(err) return nil, NewErrorInternalError(err)
} }
if blocked { if blocked {
return nil, NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID)) return nil, NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID))
} }
s := &gtsmodel.Status{} s := &gtsmodel.Status{}
if err := p.db.GetWhere([]db.Where{ if err := p.db.GetWhere([]db.Where{
{Key: "id", Value: requestedStatusID}, {Key: "id", Value: requestedStatusID},
{Key: "account_id", Value: requestedAccount.ID}, {Key: "account_id", Value: requestedAccount.ID},
}, s); err != nil { }, s); err != nil {
return nil, NewErrorNotFound(fmt.Errorf("database error getting status with id %s and account id %s: %s", requestedStatusID, requestedAccount.ID, err)) return nil, NewErrorNotFound(fmt.Errorf("database error getting status with id %s and account id %s: %s", requestedStatusID, requestedAccount.ID, err))
} }
asStatus, err := p.tc.StatusToAS(s) asStatus, err := p.tc.StatusToAS(s)
if err != nil { if err != nil {
return nil, NewErrorInternalError(err) return nil, NewErrorInternalError(err)
} }
data, err := streams.Serialize(asStatus) data, err := streams.Serialize(asStatus)
if err != nil { if err != nil {
return nil, NewErrorInternalError(err) return nil, NewErrorInternalError(err)
} }
return data, nil return data, nil
} }
func (p *processor) GetWebfingerAccount(requestedUsername string, request *http.Request) (*apimodel.WebfingerAccountResponse, ErrorWithCode) { func (p *processor) GetWebfingerAccount(requestedUsername string, request *http.Request) (*apimodel.WebfingerAccountResponse, ErrorWithCode) {

View File

@ -25,5 +25,5 @@ func (p *processor) notifyStatus(status *gtsmodel.Status) error {
} }
func (p *processor) notifyFollow(follow *gtsmodel.Follow) error { func (p *processor) notifyFollow(follow *gtsmodel.Follow) error {
return nil return nil
} }

View File

@ -56,7 +56,7 @@ func (p *processor) FollowRequestAccept(auth *oauth.Auth, accountID string) (*ap
p.fromClientAPI <- gtsmodel.FromClientAPI{ p.fromClientAPI <- gtsmodel.FromClientAPI{
APActivityType: gtsmodel.ActivityStreamsAccept, APActivityType: gtsmodel.ActivityStreamsAccept,
GTSModel: follow, GTSModel: follow,
} }
gtsR, err := p.db.GetRelationship(auth.Account.ID, accountID) gtsR, err := p.db.GetRelationship(auth.Account.ID, accountID)
@ -65,7 +65,7 @@ func (p *processor) FollowRequestAccept(auth *oauth.Auth, accountID string) (*ap
} }
r, err := p.tc.RelationshipToMasto(gtsR) r, err := p.tc.RelationshipToMasto(gtsR)
if err != nil { if err != nil {
return nil, NewErrorInternalError(err) return nil, NewErrorInternalError(err)
} }

View File

@ -121,6 +121,9 @@ type Processor interface {
// StatusUnfave processes the unfaving of a given status, returning the updated status if the fave goes through. // StatusUnfave processes the unfaving of a given status, returning the updated status if the fave goes through.
StatusUnfave(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) StatusUnfave(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error)
// 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.Status, ErrorWithCode)
/* /*
FEDERATION API-FACING PROCESSING FUNCTIONS FEDERATION API-FACING PROCESSING FUNCTIONS
These functions are intended to be called when the federating client needs an immediate (ie., synchronous) reply These functions are intended to be called when the federating client needs an immediate (ie., synchronous) reply

View File

@ -0,0 +1,67 @@
package message
import (
"fmt"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
func (p *processor) HomeTimelineGet(authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) ([]apimodel.Status, ErrorWithCode) {
statuses, err := p.db.GetHomeTimelineForAccount(authed.Account.ID, maxID, sinceID, minID, limit, local)
if err != nil {
return nil, NewErrorInternalError(err)
}
apiStatuses := []apimodel.Status{}
for _, s := range statuses {
targetAccount := &gtsmodel.Account{}
if err := p.db.GetByID(s.AccountID, targetAccount); err != nil {
return nil, NewErrorInternalError(fmt.Errorf("error getting status author: %s", err))
}
relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(s)
if err != nil {
return nil, NewErrorInternalError(fmt.Errorf("error getting relevant statuses: %s", err))
}
visible, err := p.db.StatusVisible(s, targetAccount, authed.Account, relevantAccounts)
if err != nil {
return nil, NewErrorInternalError(fmt.Errorf("error checking status visibility: %s", err))
}
if !visible {
continue
}
var boostedStatus *gtsmodel.Status
if s.BoostOfID != "" {
bs := &gtsmodel.Status{}
if err := p.db.GetByID(s.BoostOfID, bs); err != nil {
return nil, NewErrorInternalError(fmt.Errorf("error getting boosted status: %s", err))
}
boostedRelevantAccounts, err := p.db.PullRelevantAccountsFromStatus(bs)
if err != nil {
return nil, NewErrorInternalError(fmt.Errorf("error getting relevant accounts from boosted status: %s", err))
}
boostedVisible, err := p.db.StatusVisible(bs, relevantAccounts.BoostedAccount, authed.Account, boostedRelevantAccounts)
if err != nil {
return nil, NewErrorInternalError(fmt.Errorf("error checking boosted status visibility: %s", err))
}
if boostedVisible {
boostedStatus = bs
}
}
apiStatus, err := p.tc.StatusToMasto(s, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostedStatus)
if err != nil {
return nil, NewErrorInternalError(fmt.Errorf("error converting status to masto: %s", err))
}
apiStatuses = append(apiStatuses, *apiStatus)
}
return apiStatuses, nil
}

View File

@ -121,7 +121,7 @@ func (c *converter) ASRepresentationToAccount(accountable Accountable) (*gtsmode
acct.URL = url.String() acct.URL = url.String()
// InboxURI // InboxURI
if accountable.GetActivityStreamsInbox() != nil || accountable.GetActivityStreamsInbox().GetIRI() != nil { if accountable.GetActivityStreamsInbox() != nil && accountable.GetActivityStreamsInbox().GetIRI() != nil {
acct.InboxURI = accountable.GetActivityStreamsInbox().GetIRI().String() acct.InboxURI = accountable.GetActivityStreamsInbox().GetIRI().String()
} }

View File

@ -439,7 +439,7 @@ func (c *converter) StatusToAS(s *gtsmodel.Status) (vocab.ActivityStreamsNote, e
// replies // replies
// TODO // TODO
return status, nil return status, nil
} }

View File

@ -575,18 +575,18 @@ func (c *converter) InstanceToMasto(i *gtsmodel.Instance) (*model.Instance, erro
func (c *converter) RelationshipToMasto(r *gtsmodel.Relationship) (*model.Relationship, error) { func (c *converter) RelationshipToMasto(r *gtsmodel.Relationship) (*model.Relationship, error) {
return &model.Relationship{ return &model.Relationship{
ID: r.ID, ID: r.ID,
Following: r.Following, Following: r.Following,
ShowingReblogs: r.ShowingReblogs, ShowingReblogs: r.ShowingReblogs,
Notifying: r.Notifying, Notifying: r.Notifying,
FollowedBy: r.FollowedBy, FollowedBy: r.FollowedBy,
Blocking: r.Blocking, Blocking: r.Blocking,
BlockedBy: r.BlockedBy, BlockedBy: r.BlockedBy,
Muting: r.Muting, Muting: r.Muting,
MutingNotifications: r.MutingNotifications, MutingNotifications: r.MutingNotifications,
Requested: r.Requested, Requested: r.Requested,
DomainBlocking: r.DomainBlocking, DomainBlocking: r.DomainBlocking,
Endorsed: r.Endorsed, Endorsed: r.Endorsed,
Note: r.Note, Note: r.Note,
}, nil }, nil
} }