// GoToSocial // Copyright (C) GoToSocial Authors admin@gotosocial.org // SPDX-License-Identifier: AGPL-3.0-or-later // // 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 bundb import ( "context" "errors" "slices" "time" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/paging" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/uptrace/bun" ) type timelineDB struct { db *bun.DB state *state.State } func (t *timelineDB) GetHomeTimeline(ctx context.Context, accountID string, page *paging.Page) ([]*gtsmodel.Status, error) { return loadStatusTimelinePage(ctx, t.db, t.state, // Paging // params. page, // The actual meat of the home-timeline query, outside // of any paging parameters that selects by followings. func(q *bun.SelectQuery) (*bun.SelectQuery, error) { // As this is the home timeline, it should be // populated by statuses from accounts followed // by accountID, and posts from accountID itself. // // So, begin by seeing who accountID follows. // It should be a little cheaper to do this in // a separate query like this, rather than using // a join, since followIDs are cached in memory. follows, err := t.state.DB.GetAccountFollows( gtscontext.SetBarebones(ctx), accountID, nil, // select all ) if err != nil && !errors.Is(err, db.ErrNoEntries) { return nil, gtserror.Newf("db error getting follows for account %s: %w", accountID, err) } // To take account of exclusive lists, get all of // this account's lists, so we can filter out follows // that are in contained in exclusive lists. lists, err := t.state.DB.GetListsByAccountID(ctx, accountID) if err != nil && !errors.Is(err, db.ErrNoEntries) { return nil, gtserror.Newf("db error getting lists for account %s: %w", accountID, err) } // Index all follow IDs that fall in exclusive lists. ignoreFollowIDs := make(map[string]struct{}) for _, list := range lists { if !*list.Exclusive { // Not exclusive, // we don't care. continue } // Fetch all follow IDs of the entries ccontained in this list. listFollowIDs, err := t.state.DB.GetFollowIDsInList(ctx, list.ID, nil) if err != nil && !errors.Is(err, db.ErrNoEntries) { return nil, gtserror.Newf("db error getting list entry follow ids: %w", err) } // Exclusive list, index all its follow IDs. for _, followID := range listFollowIDs { ignoreFollowIDs[followID] = struct{}{} } } // Extract just the accountID from each follow, // ignoring follows that are in exclusive lists. targetAccountIDs := make([]string, 0, len(follows)+1) for _, f := range follows { _, ignore := ignoreFollowIDs[f.ID] if !ignore { targetAccountIDs = append( targetAccountIDs, f.TargetAccountID, ) } } // Add accountID itself as a pseudo follow so that // accountID can see its own posts in the timeline. targetAccountIDs = append(targetAccountIDs, accountID) // Select only statuses authored by // accounts with IDs in the slice. q = q.Where( "? IN (?)", bun.Ident("account_id"), bun.In(targetAccountIDs), ) // Only include statuses that aren't pending approval. q = q.Where("NOT ? = ?", bun.Ident("pending_approval"), true) return q, nil }, ) } func (t *timelineDB) GetPublicTimeline(ctx context.Context, page *paging.Page) ([]*gtsmodel.Status, error) { return loadStatusTimelinePage(ctx, t.db, t.state, // Paging // params. page, func(q *bun.SelectQuery) (*bun.SelectQuery, error) { // Public only. q = q.Where("? = ?", bun.Ident("visibility"), gtsmodel.VisibilityPublic) // Ignore boosts. q = q.Where("? IS NULL", bun.Ident("boost_of_id")) // Only include statuses that aren't pending approval. q = q.Where("? = ?", bun.Ident("pending_approval"), false) return q, nil }, ) } func (t *timelineDB) getLocalTimeline( ctx context.Context, maxID string, sinceID string, minID string, limit int, ) ([]*gtsmodel.Status, error) { // Make educated guess for slice size var ( statusIDs = make([]string, 0, limit) frontToBack = true ) q := t.db. NewSelect(). TableExpr("? AS ?", bun.Ident("statuses"), bun.Ident("status")). // Local only. Where("? = ?", bun.Ident("status.local"), true). // Public only. Where("? = ?", bun.Ident("status.visibility"), gtsmodel.VisibilityPublic). // Only include statuses that aren't pending approval. Where("? = ?", bun.Ident("status.pending_approval"), false). // Ignore boosts. Where("? IS NULL", bun.Ident("status.boost_of_id")). // Select only IDs from table Column("status.id") if maxID == "" || maxID >= id.Highest { const future = 24 * time.Hour // don't return statuses more than 24hr in the future maxID = id.NewULIDFromTime(time.Now().Add(future)) } // return only statuses LOWER (ie., older) than maxID q = q.Where("? < ?", bun.Ident("status.id"), maxID) if sinceID != "" { // return only statuses HIGHER (ie., newer) than sinceID q = q.Where("? > ?", bun.Ident("status.id"), sinceID) } if minID != "" { // return only statuses HIGHER (ie., newer) than minID q = q.Where("? > ?", bun.Ident("status.id"), minID) // page up frontToBack = false } if limit > 0 { // limit amount of statuses returned q = q.Limit(limit) } if frontToBack { // Page down. q = q.Order("status.id DESC") } else { // Page up. q = q.Order("status.id ASC") } if err := q.Scan(ctx, &statusIDs); err != nil { return nil, err } if len(statusIDs) == 0 { return nil, nil } // If we're paging up, we still want statuses // to be sorted by ID desc, so reverse ids slice. // https://zchee.github.io/golang-wiki/SliceTricks/#reversing if !frontToBack { for l, r := 0, len(statusIDs)-1; l < r; l, r = l+1, r-1 { statusIDs[l], statusIDs[r] = statusIDs[r], statusIDs[l] } } // Return status IDs loaded from cache + db. return t.state.DB.GetStatusesByIDs(ctx, statusIDs) } // 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 (t *timelineDB) GetFavedTimeline(ctx context.Context, accountID string, maxID string, minID string, limit int) ([]*gtsmodel.Status, string, string, error) { // Ensure reasonable if limit < 0 { limit = 0 } // Make educated guess for slice size faves := make([]*gtsmodel.StatusFave, 0, limit) fq := t.db. NewSelect(). Model(&faves). Where("? = ?", bun.Ident("status_fave.account_id"), accountID). Order("status_fave.id DESC") if maxID != "" { fq = fq.Where("? < ?", bun.Ident("status_fave.id"), maxID) } if minID != "" { fq = fq.Where("? > ?", bun.Ident("status_fave.id"), minID) } if limit > 0 { fq = fq.Limit(limit) } err := fq.Scan(ctx) if err != nil { return nil, "", "", err } if len(faves) == 0 { return nil, "", "", db.ErrNoEntries } // Sort by favourite ID rather than status ID slices.SortFunc(faves, func(a, b *gtsmodel.StatusFave) int { const k = -1 switch { case a.ID > b.ID: return +k case a.ID < b.ID: return -k default: return 0 } }) // Convert fave IDs to status IDs. statusIDs := make([]string, len(faves)) for i, fave := range faves { statusIDs[i] = fave.StatusID } statuses, err := t.state.DB.GetStatusesByIDs(ctx, statusIDs) if err != nil { return nil, "", "", err } nextMaxID := faves[len(faves)-1].ID prevMinID := faves[0].ID return statuses, nextMaxID, prevMinID, nil } func (t *timelineDB) GetListTimeline(ctx context.Context, listID string, page *paging.Page) ([]*gtsmodel.Status, error) { return loadStatusTimelinePage(ctx, t.db, t.state, // Paging // params. page, // The actual meat of the list-timeline query, outside // of any paging parameters, it selects by list entries. func(q *bun.SelectQuery) (*bun.SelectQuery, error) { // Fetch all follow IDs contained in list from DB. followIDs, err := t.state.DB.GetFollowIDsInList( ctx, listID, nil, ) if err != nil { return nil, gtserror.Newf("error getting follows in list: %w", err) } // Select target account // IDs from list follows. subQ := t.db.NewSelect(). TableExpr("? AS ?", bun.Ident("follows"), bun.Ident("follow")). Column("follow.target_account_id"). Where("? IN (?)", bun.Ident("follow.id"), bun.In(followIDs)) q = q.Where("? IN (?)", bun.Ident("status.account_id"), subQ) // Only include statuses that aren't pending approval. q = q.Where("NOT ? = ?", bun.Ident("pending_approval"), true) return q, nil }, ) } func (t *timelineDB) GetTagTimeline(ctx context.Context, tagID string, page *paging.Page) ([]*gtsmodel.Status, error) { return loadStatusTimelinePage(ctx, t.db, t.state, // Paging // params. page, // The actual meat of the list-timeline query, outside of any // paging params, selects by status tags with public visibility. func(q *bun.SelectQuery) (*bun.SelectQuery, error) { // ... q = q.Join( "INNER JOIN ? ON ? = ?", bun.Ident("status_to_tags"), bun.Ident("status.id"), bun.Ident("status_to_tags.status_id"), ) // This tag only. q = q.Where("? = ?", bun.Ident("status_to_tags.tag_id"), tagID) // Public only. q = q.Where("? = ?", bun.Ident("status.visibility"), gtsmodel.VisibilityPublic) return q, nil }, ) } func loadStatusTimelinePage( ctx context.Context, db *bun.DB, state *state.State, page *paging.Page, query func(*bun.SelectQuery) (*bun.SelectQuery, error), ) ( []*gtsmodel.Status, error, ) { // Extract page params. minID := page.Min.Value maxID := page.Max.Value limit := page.Limit order := page.Order() // Pre-allocate slice of IDs as dest. statusIDs := make([]string, 0, limit) // Now start building the database query. // // Select the following: // - status ID q := db.NewSelect(). Table("statuses"). Column("id") if maxID != "" { // Set a maximum ID boundary if was given. q = q.Where("? < ?", bun.Ident("id"), maxID) } if minID != "" { // Set a minimum ID boundary if was given. q = q.Where("? > ?", bun.Ident("id"), minID) } // Append caller // query details. q, err := query(q) if err != nil { return nil, err } // Set ordering. switch order { case paging.OrderAscending: q = q.OrderExpr("? ASC", bun.Ident("id")) case paging.OrderDescending: q = q.OrderExpr("? DESC", bun.Ident("id")) } // A limit should always // be supplied for this. q = q.Limit(limit) // Finally, perform query into status ID slice. if err := q.Scan(ctx, &statusIDs); err != nil { return nil, err } // The order we return from the database and // timeline caches differs depending on ordering, // but the caller always expected DESCENDING. if page.GetOrder() == paging.OrderAscending { slices.Reverse(statusIDs) } // Fetch statuses from DB / cache with given IDs. return state.DB.GetStatusesByIDs(ctx, statusIDs) }