diff --git a/internal/api/client/account/account.go b/internal/api/client/account/account.go index 6997d582f..1e4b716f5 100644 --- a/internal/api/client/account/account.go +++ b/internal/api/client/account/account.go @@ -34,6 +34,15 @@ import ( const ( // LimitKey is for setting the return amount limit for eg., requesting an account's statuses LimitKey = "limit" + // ExcludeRepliesKey is for specifying whether to exclude replies in a list of returned statuses by an account. + ExcludeRepliesKey = "exclude_replies" + // PinnedKey is for specifying whether to include pinned statuses in a list of returned statuses by an account. + PinnedKey = "pinned" + // MaxIDKey is for specifying the maximum ID of the status to retrieve. + MaxIDKey = "max_id" + // MediaOnlyKey is for specifying that only statuses with media should be returned in a list of returned statuses by an account. + MediaOnlyKey = "only_media" + // IDKey is the key to use for retrieving account ID in requests IDKey = "id" // BasePath is the base API path for this module diff --git a/internal/api/client/account/statuses.go b/internal/api/client/account/statuses.go index 11ccd29dc..f03a942f3 100644 --- a/internal/api/client/account/statuses.go +++ b/internal/api/client/account/statuses.go @@ -27,15 +27,27 @@ import ( ) // AccountStatusesGETHandler serves the statuses of the requested account, if they're visible to the requester. +// +// Several different filters might be passed into this function in the query: +// +// limit -- show only limit number of statuses +// exclude_replies -- exclude statuses that are a reply to another status +// max_id -- the maximum ID of the status to show +// pinned -- show only pinned statuses +// media_only -- show only statuses that have media attachments func (m *Module) AccountStatusesGETHandler(c *gin.Context) { + l := m.log.WithField("func", "AccountStatusesGETHandler") + authed, err := oauth.Authed(c, false, false, false, false) if err != nil { + l.Debugf("error authing: %s", err) c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } targetAcctID := c.Param(IDKey) if targetAcctID == "" { + l.Debug("no account id specified in query") c.JSON(http.StatusBadRequest, gin.H{"error": "no account id specified"}) return } @@ -43,16 +55,60 @@ func (m *Module) AccountStatusesGETHandler(c *gin.Context) { limit := 30 limitString := c.Query(LimitKey) if limitString != "" { - l, err := strconv.ParseInt(limitString, 10, 64) + 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(l) + limit = int(i) } - statuses, errWithCode := m.processor.AccountStatusesGet(authed, targetAcctID, limit) + excludeReplies := false + excludeRepliesString := c.Query(ExcludeRepliesKey) + if excludeRepliesString != "" { + i, err := strconv.ParseBool(excludeRepliesString) + if err != nil { + l.Debugf("error parsing replies string: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse exclude replies query param"}) + return + } + excludeReplies = i + } + + maxID := "" + maxIDString := c.Query(MaxIDKey) + if maxIDString != "" { + maxID = maxIDString + } + + pinned := false + pinnedString := c.Query(PinnedKey) + if pinnedString != "" { + i, err := strconv.ParseBool(pinnedString) + if err != nil { + l.Debugf("error parsing pinned string: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse pinned query param"}) + return + } + pinned = i + } + + mediaOnly := false + mediaOnlyString := c.Query(MediaOnlyKey) + if mediaOnlyString != "" { + i, err := strconv.ParseBool(mediaOnlyString) + if err != nil { + l.Debugf("error parsing media only string: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse media only query param"}) + return + } + mediaOnly = i + } + + statuses, errWithCode := m.processor.AccountStatusesGet(authed, targetAcctID, limit, excludeReplies, maxID, pinned, mediaOnly) if errWithCode != nil { + l.Debugf("error from processor account statuses get: %s", errWithCode) c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) return } diff --git a/internal/api/model/status.go b/internal/api/model/status.go index d90c51c93..2456d1a8f 100644 --- a/internal/api/model/status.go +++ b/internal/api/model/status.go @@ -55,7 +55,7 @@ type Status struct { // Have you bookmarked this status? Bookmarked bool `json:"bookmarked"` // Have you pinned this status? Only appears if the status is pinnable. - Pinned bool `json:"pinned"` + Pinned bool `json:"pinned,omitempty"` // HTML-encoded status content. Content string `json:"content"` // The status being reblogged. diff --git a/internal/api/s2s/user/userget.go b/internal/api/s2s/user/userget.go index 8df137f44..9d268e121 100644 --- a/internal/api/s2s/user/userget.go +++ b/internal/api/s2s/user/userget.go @@ -56,7 +56,7 @@ func (m *Module) UsersGETHandler(c *gin.Context) { // make a copy of the context to pass along so we don't break anything cp := c.Copy() - user, err := m.processor.GetFediUser(requestedUsername, cp.Request) // GetAPUser handles auth as well + user, err := m.processor.GetFediUser(requestedUsername, cp.Request) // GetFediUser handles auth as well if err != nil { l.Info(err.Error()) c.JSON(err.Code(), gin.H{"error": err.Safe()}) diff --git a/internal/db/db.go b/internal/db/db.go index a354ddee8..cbcd698c9 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -160,16 +160,14 @@ type DB interface { // In case of no entries, a 'no entries' error will be returned GetFavesByAccountID(accountID string, faves *[]gtsmodel.StatusFave) error - // GetStatusesByAccountID is a shortcut for the common action of fetching a list of statuses produced by accountID. - // The given slice 'statuses' will be set to the result of the query, whatever it is. - // In case of no entries, a 'no entries' error will be returned - GetStatusesByAccountID(accountID string, statuses *[]gtsmodel.Status) error + // CountStatusesByAccountID is a shortcut for the common action of counting statuses produced by accountID. + CountStatusesByAccountID(accountID string) (int, error) // GetStatusesByTimeDescending is a shortcut for getting the most recent statuses. accountID is optional, if not provided // then all statuses will be returned. If limit is set to 0, the size of the returned slice will not be limited. This can // be very memory intensive so you probably shouldn't do this! // In case of no entries, a 'no entries' error will be returned - GetStatusesByTimeDescending(accountID string, statuses *[]gtsmodel.Status, limit int) error + GetStatusesByTimeDescending(accountID string, statuses *[]gtsmodel.Status, limit int, excludeReplies bool, maxID string, pinned bool, mediaOnly bool) error // GetLastStatusForAccountID simply gets the most recent status by the given account. // The given slice 'status' pointer will be set to the result of the query, whatever it is. @@ -251,9 +249,6 @@ type DB interface { // StatusBookmarkedBy checks if a given status has been bookmarked by a given account ID StatusBookmarkedBy(status *gtsmodel.Status, accountID string) (bool, error) - // StatusPinnedBy checks if a given status has been pinned by a given account ID - StatusPinnedBy(status *gtsmodel.Status, accountID string) (bool, error) - // FaveStatus faves the given status, using accountID as the faver. // The returned fave will be nil if the status was already faved. FaveStatus(status *gtsmodel.Status, accountID string) (*gtsmodel.StatusFave, error) diff --git a/internal/db/pg/pg.go b/internal/db/pg/pg.go index 1e778af62..1a9502a81 100644 --- a/internal/db/pg/pg.go +++ b/internal/db/pg/pg.go @@ -456,23 +456,35 @@ func (ps *postgresService) GetFavesByAccountID(accountID string, faves *[]gtsmod return nil } -func (ps *postgresService) GetStatusesByAccountID(accountID string, statuses *[]gtsmodel.Status) error { - if err := ps.conn.Model(statuses).Where("account_id = ?", accountID).Select(); err != nil { +func (ps *postgresService) CountStatusesByAccountID(accountID string) (int, error) { + count, err := ps.conn.Model(>smodel.Status{}).Where("account_id = ?", accountID).Count() + if err != nil { if err == pg.ErrNoRows { - return db.ErrNoEntries{} + return 0, nil } - return err + return 0, err } - return nil + return count, nil } -func (ps *postgresService) GetStatusesByTimeDescending(accountID string, statuses *[]gtsmodel.Status, limit int) error { +func (ps *postgresService) GetStatusesByTimeDescending(accountID string, statuses *[]gtsmodel.Status, limit int, excludeReplies bool, maxID string, pinned bool, mediaOnly bool) error { q := ps.conn.Model(statuses).Order("created_at DESC") + if accountID != "" { + q = q.Where("account_id = ?", accountID) + } if limit != 0 { q = q.Limit(limit) } - if accountID != "" { - q = q.Where("account_id = ?", accountID) + if excludeReplies { + q = q.Where("? IS NULL", pg.Ident("in_reply_to_id")) + } + if pinned { + q = q.Where("pinned = ?", true) + } + if mediaOnly { + q = q.WhereGroup(func(q *pg.Query) (*pg.Query, error) { + return q.Where("? IS NOT NULL", pg.Ident("attachments")).Where("attachments != '{}'"), nil + }) } if err := q.Select(); err != nil { if err == pg.ErrNoRows { @@ -824,8 +836,8 @@ func (ps *postgresService) StatusVisible(targetStatus *gtsmodel.Status, targetAc return true, nil case gtsmodel.VisibilityDirect: // make sure the requesting account is mentioned in the status - for _, menchie := range targetStatus.Mentions { - if menchie == requestingAccount.ID { + for _, acct := range relevantAccounts.MentionedAccounts { + if acct.ID == requestingAccount.ID { return true, nil // yep it's mentioned! } } @@ -900,10 +912,16 @@ func (ps *postgresService) PullRelevantAccountsFromStatus(targetStatus *gtsmodel } // now get all accounts with IDs that are mentioned in the status - for _, mentionedAccountID := range targetStatus.Mentions { + for _, mentionID := range targetStatus.Mentions { + + mention := >smodel.Mention{} + if err := ps.conn.Model(mention).Where("id = ?", mentionID).Select(); err != nil { + return accounts, fmt.Errorf("error getting mention with id %s: %s", mentionID, err) + } + mentionedAccount := >smodel.Account{} - if err := ps.conn.Model(mentionedAccount).Where("id = ?", mentionedAccountID).Select(); err != nil { - return accounts, err + if err := ps.conn.Model(mentionedAccount).Where("id = ?", mention.TargetAccountID).Select(); err != nil { + return accounts, fmt.Errorf("error getting mentioned account: %s", err) } accounts.MentionedAccounts = append(accounts.MentionedAccounts, mentionedAccount) } @@ -939,10 +957,6 @@ func (ps *postgresService) StatusBookmarkedBy(status *gtsmodel.Status, accountID return ps.conn.Model(>smodel.StatusBookmark{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Exists() } -func (ps *postgresService) StatusPinnedBy(status *gtsmodel.Status, accountID string) (bool, error) { - return ps.conn.Model(>smodel.StatusPin{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Exists() -} - func (ps *postgresService) FaveStatus(status *gtsmodel.Status, accountID string) (*gtsmodel.StatusFave, error) { // first check if a fave already exists, we can just return if so existingFave := >smodel.StatusFave{} diff --git a/internal/gotosocial/actions.go b/internal/gotosocial/actions.go index fb83a4231..94b29b883 100644 --- a/internal/gotosocial/actions.go +++ b/internal/gotosocial/actions.go @@ -68,7 +68,6 @@ var models []interface{} = []interface{}{ >smodel.StatusFave{}, >smodel.StatusBookmark{}, >smodel.StatusMute{}, - >smodel.StatusPin{}, >smodel.Tag{}, >smodel.User{}, >smodel.Emoji{}, diff --git a/internal/gtsmodel/status.go b/internal/gtsmodel/status.go index 8693bce30..d0d479520 100644 --- a/internal/gtsmodel/status.go +++ b/internal/gtsmodel/status.go @@ -34,7 +34,7 @@ type Status struct { Attachments []string `pg:",array"` // Database IDs of any tags used in this status Tags []string `pg:",array"` - // Database IDs of any accounts mentioned in this status + // Database IDs of any mentions in this status Mentions []string `pg:",array"` // Database IDs of any emojis used in this status Emojis []string `pg:",array"` @@ -69,6 +69,8 @@ type Status struct { ActivityStreamsType ActivityStreamsObject // Original text of the status without formatting Text string + // Has this status been pinned by its owner? + Pinned bool /* INTERNAL MODEL NON-DATABASE FIELDS diff --git a/internal/gtsmodel/statuspin.go b/internal/gtsmodel/statuspin.go deleted file mode 100644 index 1df333387..000000000 --- a/internal/gtsmodel/statuspin.go +++ /dev/null @@ -1,33 +0,0 @@ -/* - 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 gtsmodel - -import "time" - -// StatusPin refers to a status 'pinned' to the top of an account -type StatusPin struct { - // id of this pin in the database - ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` - // when was this pin created - CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` - // id of the account that created ('did') the pinning (this should always be the same as the author of the status) - AccountID string `pg:",notnull"` - // database id of the status that has been pinned - StatusID string `pg:",notnull"` -} diff --git a/internal/message/accountprocess.go b/internal/message/accountprocess.go index 3edb871a5..a10f6d016 100644 --- a/internal/message/accountprocess.go +++ b/internal/message/accountprocess.go @@ -185,7 +185,7 @@ func (p *processor) AccountUpdate(authed *oauth.Auth, form *apimodel.UpdateCrede return acctSensitive, nil } -func (p *processor) AccountStatusesGet(authed *oauth.Auth, targetAccountID string, limit int) ([]apimodel.Status, ErrorWithCode) { +func (p *processor) AccountStatusesGet(authed *oauth.Auth, targetAccountID string, limit int, excludeReplies bool, maxID string, pinned bool, mediaOnly bool) ([]apimodel.Status, ErrorWithCode) { targetAccount := >smodel.Account{} if err := p.db.GetByID(targetAccountID, targetAccount); err != nil { if _, ok := err.(db.ErrNoEntries); ok { @@ -196,7 +196,7 @@ func (p *processor) AccountStatusesGet(authed *oauth.Auth, targetAccountID strin statuses := []gtsmodel.Status{} apiStatuses := []apimodel.Status{} - if err := p.db.GetStatusesByTimeDescending(targetAccountID, &statuses, limit); err != nil { + if err := p.db.GetStatusesByTimeDescending(targetAccountID, &statuses, limit, excludeReplies, maxID, pinned, mediaOnly); err != nil { if _, ok := err.(db.ErrNoEntries); ok { return apiStatuses, nil } @@ -206,12 +206,12 @@ func (p *processor) AccountStatusesGet(authed *oauth.Auth, targetAccountID strin for _, s := range statuses { relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(&s) if err != nil { - return nil, NewErrorInternalError(err) + 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(err) + return nil, NewErrorInternalError(fmt.Errorf("error checking status visibility: %s", err)) } if !visible { continue @@ -221,16 +221,16 @@ func (p *processor) AccountStatusesGet(authed *oauth.Auth, targetAccountID strin if s.BoostOfID != "" { bs := >smodel.Status{} if err := p.db.GetByID(s.BoostOfID, bs); err != nil { - return nil, NewErrorInternalError(err) + return nil, NewErrorInternalError(fmt.Errorf("error getting boosted status: %s", err)) } boostedRelevantAccounts, err := p.db.PullRelevantAccountsFromStatus(bs) if err != nil { - return nil, NewErrorInternalError(err) + 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(err) + return nil, NewErrorInternalError(fmt.Errorf("error checking boosted status visibility: %s", err)) } if boostedVisible { @@ -240,7 +240,7 @@ func (p *processor) AccountStatusesGet(authed *oauth.Auth, targetAccountID strin apiStatus, err := p.tc.StatusToMasto(&s, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostedStatus) if err != nil { - return nil, NewErrorInternalError(err) + return nil, NewErrorInternalError(fmt.Errorf("error converting status to masto: %s", err)) } apiStatuses = append(apiStatuses, *apiStatus) diff --git a/internal/message/fromfederatorprocess.go b/internal/message/fromfederatorprocess.go index 2863370d9..2a329c375 100644 --- a/internal/message/fromfederatorprocess.go +++ b/internal/message/fromfederatorprocess.go @@ -21,7 +21,9 @@ package message import ( "errors" "fmt" + "net/url" + "github.com/google/uuid" "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" @@ -85,7 +87,7 @@ func (p *processor) processFromFederator(federatorMsg gtsmodel.FromFederator) er // that's up the caller to do. func (p *processor) dereferenceStatusFields(status *gtsmodel.Status) error { l := p.log.WithFields(logrus.Fields{ - "func": "dereferenceStatusFields", + "func": "dereferenceStatusFields", "status": fmt.Sprintf("%+v", status), }) l.Debug("entering function") @@ -99,6 +101,12 @@ func (p *processor) dereferenceStatusFields(status *gtsmodel.Status) error { return fmt.Errorf("error creating transport: %s", err) } + // the status should have an ID by now, but just in case it doesn't let's generate one here + // because we'll need it further down + if status.ID == "" { + status.ID = uuid.NewString() + } + // 1. Media attachments. // // At this point we should know: @@ -132,7 +140,7 @@ func (p *processor) dereferenceStatusFields(status *gtsmodel.Status) error { continue } l.Debugf("dereferenced attachment: %+v", deferencedAttachment) - deferencedAttachment.StatusID = deferencedAttachment.ID + deferencedAttachment.StatusID = status.ID if err := p.db.Put(deferencedAttachment); err != nil { return fmt.Errorf("error inserting dereferenced attachment with remote url %s: %s", a.RemoteURL, err) } @@ -141,5 +149,60 @@ func (p *processor) dereferenceStatusFields(status *gtsmodel.Status) error { } status.Attachments = attachmentIDs + // 2. Hashtags + + // 3. Emojis + + // 4. Mentions + // At this point, mentions should have the namestring and mentionedAccountURI set on them. + // + // We should dereference any accounts mentioned here which we don't have in our db yet, by their URI. + mentions := []string{} + for _, m := range status.GTSMentions { + uri, err := url.Parse(m.MentionedAccountURI) + if err != nil { + l.Debugf("error parsing mentioned account uri %s: %s", m.MentionedAccountURI, err) + continue + } + + m.StatusID = status.ID + m.OriginAccountID = status.GTSAccount.ID + m.OriginAccountURI = status.GTSAccount.URI + + targetAccount := >smodel.Account{} + if err := p.db.GetWhere("uri", uri.String(), targetAccount); err != nil { + // proper error + if _, ok := err.(db.ErrNoEntries); !ok { + return fmt.Errorf("db error checking for account with uri %s", uri.String()) + } + + // we just don't have it yet, so we should go get it.... + accountable, err := p.federator.DereferenceRemoteAccount(username, uri) + if err != nil { + // we can't dereference it so just skip it + l.Debugf("error dereferencing remote account with uri %s: %s", uri.String(), err) + continue + } + + targetAccount, err = p.tc.ASRepresentationToAccount(accountable) + if err != nil { + l.Debugf("error converting remote account with uri %s into gts model: %s", uri.String(), err) + continue + } + + if err := p.db.Put(targetAccount); err != nil { + return fmt.Errorf("db error inserting account with uri %s", uri.String()) + } + } + + // by this point, we know the targetAccount exists in our database with an ID :) + m.TargetAccountID = targetAccount.ID + if err := p.db.Put(m); err != nil { + return fmt.Errorf("error creating mention: %s", err) + } + mentions = append(mentions, m.ID) + } + status.Mentions = mentions + return nil } diff --git a/internal/message/processor.go b/internal/message/processor.go index 4ed3f7af9..c9ba5f858 100644 --- a/internal/message/processor.go +++ b/internal/message/processor.go @@ -70,7 +70,7 @@ type Processor interface { AccountUpdate(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(authed *oauth.Auth, targetAccountID string, limit int) ([]apimodel.Status, ErrorWithCode) + AccountStatusesGet(authed *oauth.Auth, targetAccountID string, limit int, excludeReplies bool, maxID string, pinned bool, mediaOnly bool) ([]apimodel.Status, ErrorWithCode) // AccountFollowersGet AccountFollowersGet(authed *oauth.Auth, targetAccountID string) ([]apimodel.Account, ErrorWithCode) diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index c5c634d07..e4ccab988 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -87,16 +87,12 @@ func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*model.Account, e } // count statuses - statuses := []gtsmodel.Status{} - if err := c.db.GetStatusesByAccountID(a.ID, &statuses); err != nil { + statusesCount, err := c.db.CountStatusesByAccountID(a.ID) + if err != nil { if _, ok := err.(db.ErrNoEntries); !ok { return nil, fmt.Errorf("error getting last statuses: %s", err) } } - var statusesCount int - if statuses != nil { - statusesCount = len(statuses) - } // check when the last status was lastStatus := >smodel.Status{} @@ -295,7 +291,6 @@ func (c *converter) StatusToMasto( var faved bool var reblogged bool var bookmarked bool - var pinned bool var muted bool // requestingAccount will be nil for public requests without auth @@ -320,11 +315,6 @@ func (c *converter) StatusToMasto( if err != nil { return nil, fmt.Errorf("error checking if requesting account has bookmarked status: %s", err) } - - pinned, err = c.db.StatusPinnedBy(s, requestingAccount.ID) - if err != nil { - return nil, fmt.Errorf("error checking if requesting account has pinned status: %s", err) - } } var mastoRebloggedStatus *model.Status @@ -523,7 +513,7 @@ func (c *converter) StatusToMasto( Reblogged: reblogged, Muted: muted, Bookmarked: bookmarked, - Pinned: pinned, + Pinned: s.Pinned, Content: s.Content, Reblog: mastoRebloggedStatus, Application: mastoApplication, diff --git a/testrig/db.go b/testrig/db.go index 0b4920191..fb4a4e6e7 100644 --- a/testrig/db.go +++ b/testrig/db.go @@ -42,7 +42,6 @@ var testModels []interface{} = []interface{}{ >smodel.StatusFave{}, >smodel.StatusBookmark{}, >smodel.StatusMute{}, - >smodel.StatusPin{}, >smodel.Tag{}, >smodel.User{}, >smodel.Emoji{},