diff options
Diffstat (limited to 'internal/db')
-rw-r--r-- | internal/db/bundb/migrations/20240528071620_add_user_mutes.go | 61 | ||||
-rw-r--r-- | internal/db/bundb/relationship.go | 11 | ||||
-rw-r--r-- | internal/db/bundb/relationship_mute.go | 306 | ||||
-rw-r--r-- | internal/db/bundb/relationship_test.go | 37 | ||||
-rw-r--r-- | internal/db/relationship.go | 21 |
5 files changed, 436 insertions, 0 deletions
diff --git a/internal/db/bundb/migrations/20240528071620_add_user_mutes.go b/internal/db/bundb/migrations/20240528071620_add_user_mutes.go new file mode 100644 index 000000000..e92e4df5b --- /dev/null +++ b/internal/db/bundb/migrations/20240528071620_add_user_mutes.go @@ -0,0 +1,61 @@ +// 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 <http://www.gnu.org/licenses/>. + +package migrations + +import ( + "context" + + gtsmodel "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/uptrace/bun" +) + +func init() { + up := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + if _, err := tx. + NewCreateTable(). + Model(>smodel.UserMute{}). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + + if _, err := tx. + NewCreateIndex(). + Table("user_mutes"). + Index("user_mutes_account_id_idx"). + Column("account_id"). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + + return nil + }) + } + + down := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + return nil + }) + } + + if err := Migrations.Register(up, down); err != nil { + panic(err) + } +} diff --git a/internal/db/bundb/relationship.go b/internal/db/bundb/relationship.go index 052f29cb3..cb820d5c4 100644 --- a/internal/db/bundb/relationship.go +++ b/internal/db/bundb/relationship.go @@ -20,6 +20,7 @@ package bundb import ( "context" "errors" + "time" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtscontext" @@ -108,6 +109,16 @@ func (r *relationshipDB) GetRelationship(ctx context.Context, requestingAccount rel.Note = note.Comment } + // check if the requesting account is muting the target account + mute, err := r.GetMute(ctx, requestingAccount, targetAccount) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return nil, gtserror.Newf("error checking muting: %w", err) + } + if mute != nil && !mute.Expired(time.Now()) { + rel.Muting = true + rel.MutingNotifications = *mute.Notifications + } + return &rel, nil } diff --git a/internal/db/bundb/relationship_mute.go b/internal/db/bundb/relationship_mute.go new file mode 100644 index 000000000..3c664cbd7 --- /dev/null +++ b/internal/db/bundb/relationship_mute.go @@ -0,0 +1,306 @@ +// 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 <http://www.gnu.org/licenses/>. + +package bundb + +import ( + "context" + "errors" + "slices" + + "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/log" + "github.com/superseriousbusiness/gotosocial/internal/paging" + "github.com/superseriousbusiness/gotosocial/internal/util" + "github.com/uptrace/bun" + "github.com/uptrace/bun/dialect" +) + +func (r *relationshipDB) IsMuted(ctx context.Context, sourceAccountID string, targetAccountID string) (bool, error) { + mute, err := r.GetMute( + gtscontext.SetBarebones(ctx), + sourceAccountID, + targetAccountID, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return false, err + } + return mute != nil, nil +} + +func (r *relationshipDB) GetMuteByID(ctx context.Context, id string) (*gtsmodel.UserMute, error) { + return r.getMute( + ctx, + "ID", + func(mute *gtsmodel.UserMute) error { + return r.db.NewSelect().Model(mute). + Where("? = ?", bun.Ident("id"), id). + Scan(ctx) + }, + id, + ) +} + +func (r *relationshipDB) GetMute( + ctx context.Context, + sourceAccountID string, + targetAccountID string, +) (*gtsmodel.UserMute, error) { + return r.getMute( + ctx, + "AccountID,TargetAccountID", + func(mute *gtsmodel.UserMute) error { + return r.db.NewSelect().Model(mute). + Where("? = ?", bun.Ident("account_id"), sourceAccountID). + Where("? = ?", bun.Ident("target_account_id"), targetAccountID). + Scan(ctx) + }, + sourceAccountID, + targetAccountID, + ) +} + +func (r *relationshipDB) getMutesByIDs(ctx context.Context, ids []string) ([]*gtsmodel.UserMute, error) { + // Load all mutes IDs via cache loader callbacks. + mutes, err := r.state.Caches.GTS.UserMute.LoadIDs("ID", + ids, + func(uncached []string) ([]*gtsmodel.UserMute, error) { + // Preallocate expected length of uncached mutes. + mutes := make([]*gtsmodel.UserMute, 0, len(uncached)) + + // Perform database query scanning + // the remaining (uncached) IDs. + if err := r.db.NewSelect(). + Model(&mutes). + Where("? IN (?)", bun.Ident("id"), bun.In(uncached)). + Scan(ctx); err != nil { + return nil, err + } + + return mutes, nil + }, + ) + if err != nil { + return nil, err + } + + // Reorder the mutes by their + // IDs to ensure in correct order. + getID := func(b *gtsmodel.UserMute) string { return b.ID } + util.OrderBy(mutes, ids, getID) + + if gtscontext.Barebones(ctx) { + // no need to fully populate. + return mutes, nil + } + + // Populate all loaded mutes, removing those we fail to + // populate (removes needing so many nil checks everywhere). + mutes = slices.DeleteFunc(mutes, func(mute *gtsmodel.UserMute) bool { + if err := r.populateMute(ctx, mute); err != nil { + log.Errorf(ctx, "error populating mute %s: %v", mute.ID, err) + return true + } + return false + }) + + return mutes, nil +} + +func (r *relationshipDB) getMute( + ctx context.Context, + lookup string, + dbQuery func(*gtsmodel.UserMute) error, + keyParts ...any, +) (*gtsmodel.UserMute, error) { + // Fetch mute from cache with loader callback + mute, err := r.state.Caches.GTS.UserMute.LoadOne(lookup, func() (*gtsmodel.UserMute, error) { + var mute gtsmodel.UserMute + + // Not cached! Perform database query + if err := dbQuery(&mute); err != nil { + return nil, err + } + + return &mute, nil + }, keyParts...) + if err != nil { + // already processe + return nil, err + } + + if gtscontext.Barebones(ctx) { + // Only a barebones model was requested. + return mute, nil + } + + if err := r.populateMute(ctx, mute); err != nil { + return nil, err + } + + return mute, nil +} + +func (r *relationshipDB) populateMute(ctx context.Context, mute *gtsmodel.UserMute) error { + var ( + errs gtserror.MultiError + err error + ) + + if mute.Account == nil { + // Mute origin account is not set, fetch from database. + mute.Account, err = r.state.DB.GetAccountByID( + gtscontext.SetBarebones(ctx), + mute.AccountID, + ) + if err != nil { + errs.Appendf("error populating mute account: %w", err) + } + } + + if mute.TargetAccount == nil { + // Mute target account is not set, fetch from database. + mute.TargetAccount, err = r.state.DB.GetAccountByID( + gtscontext.SetBarebones(ctx), + mute.TargetAccountID, + ) + if err != nil { + errs.Appendf("error populating mute target account: %w", err) + } + } + + return errs.Combine() +} + +func (r *relationshipDB) PutMute(ctx context.Context, mute *gtsmodel.UserMute) error { + return r.state.Caches.GTS.UserMute.Store(mute, func() error { + _, err := NewUpsert(r.db).Model(mute).Constraint("id").Exec(ctx) + return err + }) +} + +func (r *relationshipDB) DeleteMuteByID(ctx context.Context, id string) error { + // Load mute into cache before attempting a delete, + // as we need it cached in order to trigger the invalidate + // callback. This in turn invalidates others. + _, err := r.GetMuteByID(gtscontext.SetBarebones(ctx), id) + if err != nil { + if errors.Is(err, db.ErrNoEntries) { + // not an issue. + err = nil + } + return err + } + + // Drop this now-cached mute on return after delete. + defer r.state.Caches.GTS.UserMute.Invalidate("ID", id) + + // Finally delete mute from DB. + _, err = r.db.NewDelete(). + Table("user_mutes"). + Where("? = ?", bun.Ident("id"), id). + Exec(ctx) + return err +} + +func (r *relationshipDB) DeleteAccountMutes(ctx context.Context, accountID string) error { + var muteIDs []string + + // Get full list of IDs. + if err := r.db.NewSelect(). + Column("id"). + Table("user_mutes"). + WhereOr("? = ? OR ? = ?", + bun.Ident("account_id"), + accountID, + bun.Ident("target_account_id"), + accountID, + ). + Scan(ctx, &muteIDs); err != nil { + return err + } + + defer func() { + // Invalidate all account's incoming / outoing mutes on return. + r.state.Caches.GTS.UserMute.Invalidate("AccountID", accountID) + r.state.Caches.GTS.UserMute.Invalidate("TargetAccountID", accountID) + }() + + // Load all mutes into cache, this *really* isn't great + // but it is the only way we can ensure we invalidate all + // related caches correctly (e.g. visibility). + _, err := r.GetAccountMutes(ctx, accountID, nil) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return err + } + + // Finally delete all from DB. + _, err = r.db.NewDelete(). + Table("user_mutes"). + Where("? IN (?)", bun.Ident("id"), bun.In(muteIDs)). + Exec(ctx) + return err +} + +func (r *relationshipDB) GetAccountMutes( + ctx context.Context, + accountID string, + page *paging.Page, +) ([]*gtsmodel.UserMute, error) { + muteIDs, err := r.getAccountMuteIDs(ctx, accountID, page) + if err != nil { + return nil, err + } + return r.getMutesByIDs(ctx, muteIDs) +} + +func (r *relationshipDB) getAccountMuteIDs(ctx context.Context, accountID string, page *paging.Page) ([]string, error) { + return loadPagedIDs(&r.state.Caches.GTS.UserMuteIDs, accountID, page, func() ([]string, error) { + var muteIDs []string + + // Mute IDs not in cache. Perform DB query. + if _, err := r.db. + NewSelect(). + TableExpr("?", bun.Ident("user_mutes")). + ColumnExpr("?", bun.Ident("id")). + Where("? = ?", bun.Ident("account_id"), accountID). + WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery { + var notYetExpiredSQL string + switch r.db.Dialect().Name() { + case dialect.SQLite: + notYetExpiredSQL = "? > DATE('now')" + case dialect.PG: + notYetExpiredSQL = "? > NOW()" + default: + log.Panicf(nil, "db conn %s was neither pg nor sqlite", r.db) + } + return q. + Where("? IS NULL", bun.Ident("expires_at")). + WhereOr(notYetExpiredSQL, bun.Ident("expires_at")) + }). + OrderExpr("? DESC", bun.Ident("id")). + Exec(ctx, &muteIDs); // nocollapse + err != nil && !errors.Is(err, db.ErrNoEntries) { + return nil, err + } + + return muteIDs, nil + }) +} diff --git a/internal/db/bundb/relationship_test.go b/internal/db/bundb/relationship_test.go index f1d1a35d2..46a4f1f25 100644 --- a/internal/db/bundb/relationship_test.go +++ b/internal/db/bundb/relationship_test.go @@ -510,6 +510,43 @@ func (suite *RelationshipTestSuite) TestDeleteAccountBlocks() { suite.Nil(block) } +func (suite *RelationshipTestSuite) TestDeleteAccountMutes() { + ctx := context.Background() + + // Add a mute. + accountID1 := suite.testAccounts["local_account_1"].ID + accountID2 := suite.testAccounts["local_account_2"].ID + muteID := "01HZGZ3F3C7S1TTPE8F9VPZDCB" + err := suite.db.PutMute(ctx, >smodel.UserMute{ + ID: muteID, + AccountID: accountID1, + TargetAccountID: accountID2, + }) + if err != nil { + suite.FailNow(err.Error()) + } + + // Make sure the mute is in the DB. + mute, err := suite.db.GetMute(ctx, accountID1, accountID2) + if err != nil { + suite.FailNow(err.Error()) + } + if suite.NotNil(mute) { + suite.Equal(muteID, mute.ID) + } + + // Delete all mutes owned by that account. + err = suite.db.DeleteAccountMutes(ctx, accountID1) + if err != nil { + suite.FailNow(err.Error()) + } + + // Mute should be gone. + mute, err = suite.db.GetMute(ctx, accountID1, accountID2) + suite.ErrorIs(err, db.ErrNoEntries) + suite.Nil(mute) +} + func (suite *RelationshipTestSuite) TestGetRelationship() { requestingAccount := suite.testAccounts["local_account_1"] targetAccount := suite.testAccounts["admin_account"] diff --git a/internal/db/relationship.go b/internal/db/relationship.go index cd4539791..5e0650fb7 100644 --- a/internal/db/relationship.go +++ b/internal/db/relationship.go @@ -187,4 +187,25 @@ type Relationship interface { // PopulateNote populates the struct pointers on the given note. PopulateNote(ctx context.Context, note *gtsmodel.AccountNote) error + + // IsMuted checks whether source account has a mute in place against target. + IsMuted(ctx context.Context, sourceAccountID string, targetAccountID string) (bool, error) + + // GetMuteByID fetches mute with given ID from the database. + GetMuteByID(ctx context.Context, id string) (*gtsmodel.UserMute, error) + + // GetMute returns the mute from account1 targeting account2, if it exists, or an error if it doesn't. + GetMute(ctx context.Context, account1 string, account2 string) (*gtsmodel.UserMute, error) + + // PutMute attempts to insert or update the given account mute in the database. + PutMute(ctx context.Context, mute *gtsmodel.UserMute) error + + // DeleteMuteByID removes mute with given ID from the database. + DeleteMuteByID(ctx context.Context, id string) error + + // DeleteAccountMutes will delete all database mutes to / from the given account ID. + DeleteAccountMutes(ctx context.Context, accountID string) error + + // GetAccountMutes returns all mutes originating from the given account, with given optional paging parameters. + GetAccountMutes(ctx context.Context, accountID string, paging *paging.Page) ([]*gtsmodel.UserMute, error) } |