diff options
author | 2024-06-06 09:38:02 -0700 | |
---|---|---|
committer | 2024-06-06 16:38:02 +0000 | |
commit | 5e2d4fdb19eb4fcd4c0bbfb3e2f29067a58c88c8 (patch) | |
tree | 607006af6b4bb63bb625b39f3ca0fe869eb6ba95 /internal | |
parent | [bugfix] update media if more than just url changes (#2970) (diff) | |
download | gotosocial-5e2d4fdb19eb4fcd4c0bbfb3e2f29067a58c88c8.tar.xz |
[feature] User muting (#2960)
* User muting
* Address review feedback
* Rename uniqueness constraint on user_mutes to match convention
* Remove unused account_id from where clause
* Add UserMute to NewTestDB
* Update test/envparsing.sh with new and fixed cache stuff
* Address tobi's review comments
* Make compiledUserMuteListEntry.expired consistent with UserMute.Expired
* Make sure mute_expires_at is serialized as an explicit null for indefinite mutes
---------
Co-authored-by: tobi <tobi.smethurst@protonmail.com>
Diffstat (limited to 'internal')
43 files changed, 2099 insertions, 43 deletions
diff --git a/internal/api/client/accounts/accounts.go b/internal/api/client/accounts/accounts.go index 61fdc41ad..0dbc5ea53 100644 --- a/internal/api/client/accounts/accounts.go +++ b/internal/api/client/accounts/accounts.go @@ -45,12 +45,14 @@ const ( FollowPath = BasePathWithID + "/follow" ListsPath = BasePathWithID + "/lists" LookupPath = BasePath + "/lookup" + MutePath = BasePathWithID + "/mute" NotePath = BasePathWithID + "/note" RelationshipsPath = BasePath + "/relationships" SearchPath = BasePath + "/search" StatusesPath = BasePathWithID + "/statuses" UnblockPath = BasePathWithID + "/unblock" UnfollowPath = BasePathWithID + "/unfollow" + UnmutePath = BasePathWithID + "/unmute" UpdatePath = BasePath + "/update_credentials" VerifyPath = BasePath + "/verify_credentials" MovePath = BasePath + "/move" @@ -117,6 +119,10 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H // account note attachHandler(http.MethodPost, NotePath, m.AccountNotePOSTHandler) + // mute or unmute account + attachHandler(http.MethodPost, MutePath, m.AccountMutePOSTHandler) + attachHandler(http.MethodPost, UnmutePath, m.AccountUnmutePOSTHandler) + // search for accounts attachHandler(http.MethodGet, SearchPath, m.AccountSearchGETHandler) attachHandler(http.MethodGet, LookupPath, m.AccountLookupGETHandler) diff --git a/internal/api/client/accounts/mute.go b/internal/api/client/accounts/mute.go new file mode 100644 index 000000000..37cd3bbff --- /dev/null +++ b/internal/api/client/accounts/mute.go @@ -0,0 +1,170 @@ +// 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 accounts + +import ( + "errors" + "fmt" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +// AccountMutePOSTHandler swagger:operation POST /api/v1/accounts/{id}/mute accountMute +// +// Mute account by ID. +// +// If account was already muted, succeeds anyway. This can be used to update the details of a mute. +// +// --- +// tags: +// - accounts +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: The ID of the account to block. +// in: path +// required: true +// - +// name: notifications +// type: boolean +// description: Mute notifications as well as posts. +// in: formData +// required: false +// default: false +// - +// name: duration +// type: number +// description: How long the mute should last, in seconds. If 0 or not provided, mute lasts indefinitely. +// in: formData +// required: false +// default: 0 +// +// security: +// - OAuth2 Bearer: +// - write:mutes +// +// responses: +// '200': +// description: Your relationship to the account. +// schema: +// "$ref": "#/definitions/accountRelationship" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden to moved accounts +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) AccountMutePOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if authed.Account.IsMoving() { + apiutil.ForbiddenAfterMove(c) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + targetAcctID := c.Param(IDKey) + if targetAcctID == "" { + err := errors.New("no account id specified") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + return + } + + form := &apimodel.UserMuteCreateUpdateRequest{} + if err := c.ShouldBind(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if err := normalizeCreateUpdateMute(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, err.Error()), m.processor.InstanceGetV1) + return + } + + relationship, errWithCode := m.processor.Account().MuteCreate( + c.Request.Context(), + authed.Account, + targetAcctID, + form, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, relationship) +} + +func normalizeCreateUpdateMute(form *apimodel.UserMuteCreateUpdateRequest) error { + // Apply defaults for missing fields. + form.Notifications = util.Ptr(util.PtrValueOr(form.Notifications, false)) + + // Normalize mute duration if necessary. + // If we parsed this as JSON, expires_in + // may be either a float64 or a string. + if ei := form.DurationI; ei != nil { + switch e := ei.(type) { + case float64: + form.Duration = util.Ptr(int(e)) + + case string: + duration, err := strconv.Atoi(e) + if err != nil { + return fmt.Errorf("could not parse duration value %s as integer: %w", e, err) + } + + form.Duration = &duration + + default: + return fmt.Errorf("could not parse expires_in type %T as integer", ei) + } + } + + // Interpret zero as indefinite duration. + if form.Duration != nil && *form.Duration == 0 { + form.Duration = nil + } + + return nil +} diff --git a/internal/api/client/accounts/mute_test.go b/internal/api/client/accounts/mute_test.go new file mode 100644 index 000000000..d181a2e3b --- /dev/null +++ b/internal/api/client/accounts/mute_test.go @@ -0,0 +1,173 @@ +// 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 accounts_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/accounts" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type MuteTestSuite struct { + AccountStandardTestSuite +} + +func (suite *MuteTestSuite) postMute( + accountID string, + notifications *bool, + duration *int, + requestJson *string, + expectedHTTPStatus int, + expectedBody string, +) (*apimodel.Relationship, error) { + // instantiate recorder + test context + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + + // create the request + ctx.Request = httptest.NewRequest(http.MethodPut, config.GetProtocol()+"://"+config.GetHost()+"/api/"+accounts.BasePath+"/"+accountID+"/mute", nil) + ctx.Request.Header.Set("accept", "application/json") + if requestJson != nil { + ctx.Request.Header.Set("content-type", "application/json") + ctx.Request.Body = io.NopCloser(strings.NewReader(*requestJson)) + } else { + ctx.Request.Form = make(url.Values) + if notifications != nil { + ctx.Request.Form["notifications"] = []string{strconv.FormatBool(*notifications)} + } + if duration != nil { + ctx.Request.Form["duration"] = []string{strconv.Itoa(*duration)} + } + } + + ctx.AddParam("id", accountID) + + // trigger the handler + suite.accountsModule.AccountMutePOSTHandler(ctx) + + // read the response + result := recorder.Result() + defer result.Body.Close() + + b, err := io.ReadAll(result.Body) + if err != nil { + return nil, err + } + + errs := gtserror.NewMultiError(2) + + // check code + body + if resultCode := recorder.Code; expectedHTTPStatus != resultCode { + errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) + if expectedBody == "" { + return nil, errs.Combine() + } + } + + // if we got an expected body, return early + if expectedBody != "" { + if string(b) != expectedBody { + errs.Appendf("expected %s got %s", expectedBody, string(b)) + } + return nil, errs.Combine() + } + + resp := &apimodel.Relationship{} + if err := json.Unmarshal(b, resp); err != nil { + return nil, err + } + + return resp, nil +} + +func (suite *MuteTestSuite) TestPostMuteFull() { + accountID := suite.testAccounts["remote_account_1"].ID + notifications := true + duration := 86400 + relationship, err := suite.postMute(accountID, ¬ifications, &duration, nil, http.StatusOK, "") + if err != nil { + suite.FailNow(err.Error()) + } + + suite.True(relationship.Muting) + suite.Equal(notifications, relationship.MutingNotifications) +} + +func (suite *MuteTestSuite) TestPostMuteFullJSON() { + accountID := suite.testAccounts["remote_account_2"].ID + // Use a numeric literal with a fractional part to test the JSON-specific handling for non-integer "duration". + requestJson := `{ + "notifications": true, + "duration": 86400.1 + }` + relationship, err := suite.postMute(accountID, nil, nil, &requestJson, http.StatusOK, "") + if err != nil { + suite.FailNow(err.Error()) + } + + suite.True(relationship.Muting) + suite.True(relationship.MutingNotifications) +} + +func (suite *MuteTestSuite) TestPostMuteMinimal() { + accountID := suite.testAccounts["remote_account_3"].ID + relationship, err := suite.postMute(accountID, nil, nil, nil, http.StatusOK, "") + if err != nil { + suite.FailNow(err.Error()) + } + + suite.True(relationship.Muting) + suite.False(relationship.MutingNotifications) +} + +func (suite *MuteTestSuite) TestPostMuteSelf() { + accountID := suite.testAccounts["local_account_1"].ID + _, err := suite.postMute(accountID, nil, nil, nil, http.StatusNotAcceptable, `{"error":"Not Acceptable: getMuteTarget: account 01F8MH1H7YV1Z7D2C8K2730QBF cannot mute or unmute itself"}`) + if err != nil { + suite.FailNow(err.Error()) + } +} + +func (suite *MuteTestSuite) TestPostMuteNonexistentAccount() { + accountID := "not_even_a_real_ULID" + _, err := suite.postMute(accountID, nil, nil, nil, http.StatusNotFound, `{"error":"Not Found: getMuteTarget: target account not_even_a_real_ULID not found in the db"}`) + if err != nil { + suite.FailNow(err.Error()) + } +} + +func TestMuteTestSuite(t *testing.T) { + suite.Run(t, new(MuteTestSuite)) +} diff --git a/internal/api/client/accounts/unmute.go b/internal/api/client/accounts/unmute.go new file mode 100644 index 000000000..665c3908e --- /dev/null +++ b/internal/api/client/accounts/unmute.go @@ -0,0 +1,98 @@ +// 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 accounts + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// AccountUnmutePOSTHandler swagger:operation POST /api/v1/accounts/{id}/unmute accountUnmute +// +// Unmute account by ID. +// +// If account was already unmuted (or has never been muted), succeeds anyway. +// +// --- +// tags: +// - accounts +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: The ID of the account to unmute. +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - write:mutes +// +// responses: +// '200': +// name: account relationship +// description: Your relationship to this account. +// schema: +// "$ref": "#/definitions/accountRelationship" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) AccountUnmutePOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + targetAcctID := c.Param(IDKey) + if targetAcctID == "" { + err := errors.New("no account id specified") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + return + } + + relationship, errWithCode := m.processor.Account().MuteRemove(c.Request.Context(), authed.Account, targetAcctID) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, relationship) + +} diff --git a/internal/api/client/accounts/unmute_test.go b/internal/api/client/accounts/unmute_test.go new file mode 100644 index 000000000..5a00c3610 --- /dev/null +++ b/internal/api/client/accounts/unmute_test.go @@ -0,0 +1,136 @@ +// 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 accounts_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + + "github.com/superseriousbusiness/gotosocial/internal/api/client/accounts" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +func (suite *MuteTestSuite) postUnmute( + accountID string, + expectedHTTPStatus int, + expectedBody string, +) (*apimodel.Relationship, error) { + // instantiate recorder + test context + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + + // create the request + ctx.Request = httptest.NewRequest(http.MethodPut, config.GetProtocol()+"://"+config.GetHost()+"/api/"+accounts.BasePath+"/"+accountID+"/unmute", nil) + ctx.Request.Header.Set("accept", "application/json") + + ctx.AddParam("id", accountID) + + // trigger the handler + suite.accountsModule.AccountUnmutePOSTHandler(ctx) + + // read the response + result := recorder.Result() + defer result.Body.Close() + + b, err := io.ReadAll(result.Body) + if err != nil { + return nil, err + } + + errs := gtserror.NewMultiError(2) + + // check code + body + if resultCode := recorder.Code; expectedHTTPStatus != resultCode { + errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) + if expectedBody == "" { + return nil, errs.Combine() + } + } + + // if we got an expected body, return early + if expectedBody != "" { + if string(b) != expectedBody { + errs.Appendf("expected %s got %s", expectedBody, string(b)) + } + return nil, errs.Combine() + } + + resp := &apimodel.Relationship{} + if err := json.Unmarshal(b, resp); err != nil { + return nil, err + } + + return resp, nil +} + +func (suite *MuteTestSuite) TestPostUnmuteWithoutPreviousMute() { + accountID := suite.testAccounts["remote_account_4"].ID + relationship, err := suite.postUnmute(accountID, http.StatusOK, "") + if err != nil { + suite.FailNow(err.Error()) + } + + suite.False(relationship.Muting) + suite.False(relationship.MutingNotifications) +} + +func (suite *MuteTestSuite) TestPostWithPreviousMute() { + accountID := suite.testAccounts["local_account_2"].ID + + relationship, err := suite.postMute(accountID, nil, nil, nil, http.StatusOK, "") + if err != nil { + suite.FailNow(err.Error()) + } + + suite.True(relationship.Muting) + suite.False(relationship.MutingNotifications) + + relationship, err = suite.postUnmute(accountID, http.StatusOK, "") + if err != nil { + suite.FailNow(err.Error()) + } + + suite.False(relationship.Muting) + suite.False(relationship.MutingNotifications) +} + +func (suite *MuteTestSuite) TestPostUnmuteSelf() { + accountID := suite.testAccounts["local_account_1"].ID + _, err := suite.postUnmute(accountID, http.StatusNotAcceptable, `{"error":"Not Acceptable: getMuteTarget: account 01F8MH1H7YV1Z7D2C8K2730QBF cannot mute or unmute itself"}`) + if err != nil { + suite.FailNow(err.Error()) + } +} + +func (suite *MuteTestSuite) TestPostUnmuteNonexistentAccount() { + accountID := "not_even_a_real_ULID" + _, err := suite.postUnmute(accountID, http.StatusNotFound, `{"error":"Not Found: getMuteTarget: target account not_even_a_real_ULID not found in the db"}`) + if err != nil { + suite.FailNow(err.Error()) + } +} diff --git a/internal/api/client/mutes/mutes_test.go b/internal/api/client/mutes/mutes_test.go new file mode 100644 index 000000000..5d450e32c --- /dev/null +++ b/internal/api/client/mutes/mutes_test.go @@ -0,0 +1,136 @@ +// 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 mutes_test + +import ( + "bytes" + "fmt" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/mutes" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/email" + "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type MutesTestSuite struct { + // standard suite interfaces + suite.Suite + db db.DB + storage *storage.Driver + mediaManager *media.Manager + federator *federation.Federator + processor *processing.Processor + emailSender email.Sender + sentEmails map[string]string + state state.State + + // standard suite models + testTokens map[string]*gtsmodel.Token + testClients map[string]*gtsmodel.Client + testApplications map[string]*gtsmodel.Application + testUsers map[string]*gtsmodel.User + testAccounts map[string]*gtsmodel.Account + + // module being tested + mutesModule *mutes.Module +} + +func (suite *MutesTestSuite) SetupSuite() { + suite.testTokens = testrig.NewTestTokens() + suite.testClients = testrig.NewTestClients() + suite.testApplications = testrig.NewTestApplications() + suite.testUsers = testrig.NewTestUsers() + suite.testAccounts = testrig.NewTestAccounts() +} + +func (suite *MutesTestSuite) SetupTest() { + suite.state.Caches.Init() + testrig.StartNoopWorkers(&suite.state) + + testrig.InitTestConfig() + testrig.InitTestLog() + + suite.db = testrig.NewTestDB(&suite.state) + suite.state.DB = suite.db + suite.storage = testrig.NewInMemoryStorage() + suite.state.Storage = suite.storage + + testrig.StartTimelines( + &suite.state, + visibility.NewFilter(&suite.state), + typeutils.NewConverter(&suite.state), + ) + + suite.mediaManager = testrig.NewTestMediaManager(&suite.state) + suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager) + suite.sentEmails = make(map[string]string) + suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails) + suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager) + suite.mutesModule = mutes.New(suite.processor) + testrig.StandardDBSetup(suite.db, nil) + testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") +} + +func (suite *MutesTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) + testrig.StopWorkers(&suite.state) +} + +func (suite *MutesTestSuite) newContext(recorder *httptest.ResponseRecorder, requestMethod string, requestBody []byte, requestPath string, bodyContentType string) *gin.Context { + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + + protocol := config.GetProtocol() + host := config.GetHost() + + baseURI := fmt.Sprintf("%s://%s", protocol, host) + requestURI := fmt.Sprintf("%s/%s", baseURI, requestPath) + + ctx.Request = httptest.NewRequest(requestMethod, requestURI, bytes.NewReader(requestBody)) // the endpoint we're hitting + + if bodyContentType != "" { + ctx.Request.Header.Set("Content-Type", bodyContentType) + } + + ctx.Request.Header.Set("accept", "application/json") + + return ctx +} + +func TestMutesTestSuite(t *testing.T) { + suite.Run(t, new(MutesTestSuite)) +} diff --git a/internal/api/client/mutes/mutesget.go b/internal/api/client/mutes/mutesget.go index d609da868..7fcbc2b44 100644 --- a/internal/api/client/mutes/mutesget.go +++ b/internal/api/client/mutes/mutesget.go @@ -24,14 +24,13 @@ import ( apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/paging" ) // MutesGETHandler swagger:operation GET /api/v1/mutes mutesGet // // Get an array of accounts that requesting account has muted. // -// NOT IMPLEMENTED YET: Will currently always return an array of length 0. -// // The next and previous queries can be parsed from the returned Link header. // Example: // @@ -89,6 +88,7 @@ import ( // // responses: // '200': +// description: List of muted accounts, including when their mutes expire (if applicable). // headers: // Link: // type: string @@ -96,7 +96,7 @@ import ( // schema: // type: array // items: -// "$ref": "#/definitions/account" +// "$ref": "#/definitions/mutedAccount" // '400': // description: bad request // '401': @@ -108,7 +108,8 @@ import ( // '500': // description: internal server error func (m *Module) MutesGETHandler(c *gin.Context) { - if _, err := oauth.Authed(c, true, true, true, true); err != nil { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) return } @@ -118,5 +119,29 @@ func (m *Module) MutesGETHandler(c *gin.Context) { return } - apiutil.Data(c, http.StatusOK, apiutil.AppJSON, apiutil.EmptyJSONArray) + page, errWithCode := paging.ParseIDPage(c, + 1, // min limit + 80, // max limit + 40, // default limit + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + resp, errWithCode := m.processor.Account().MutesGet( + c.Request.Context(), + authed.Account, + page, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + if resp.LinkHeader != "" { + c.Header("Link", resp.LinkHeader) + } + + apiutil.JSON(c, http.StatusOK, resp.Items) } diff --git a/internal/api/client/mutes/mutesget_test.go b/internal/api/client/mutes/mutesget_test.go new file mode 100644 index 000000000..2e5a00c6a --- /dev/null +++ b/internal/api/client/mutes/mutesget_test.go @@ -0,0 +1,155 @@ +// 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 mutes_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "time" + + "github.com/superseriousbusiness/gotosocial/internal/api/client/mutes" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +func (suite *MutesTestSuite) getMutedAccounts( + expectedHTTPStatus int, + expectedBody string, +) ([]*apimodel.MutedAccount, error) { + // instantiate recorder + test context + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + + // create the request + ctx.Request = httptest.NewRequest(http.MethodGet, config.GetProtocol()+"://"+config.GetHost()+"/api/"+mutes.BasePath, nil) + ctx.Request.Header.Set("accept", "application/json") + + // trigger the handler + suite.mutesModule.MutesGETHandler(ctx) + + // read the response + result := recorder.Result() + defer result.Body.Close() + + b, err := io.ReadAll(result.Body) + if err != nil { + return nil, err + } + + errs := gtserror.NewMultiError(2) + + // check code + body + if resultCode := recorder.Code; expectedHTTPStatus != resultCode { + errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) + if expectedBody == "" { + return nil, errs.Combine() + } + } + + // if we got an expected body, return early + if expectedBody != "" { + if string(b) != expectedBody { + errs.Appendf("expected %s got %s", expectedBody, string(b)) + } + return nil, errs.Combine() + } + + resp := make([]*apimodel.MutedAccount, 0) + if err := json.Unmarshal(b, &resp); err != nil { + return nil, err + } + + return resp, nil +} + +func (suite *MutesTestSuite) TestGetMutedAccounts() { + // Mute a user with a finite duration. + mute1 := >smodel.UserMute{ + ID: "01HZQ4K4MJTZ3RWVAEEJQDKK7M", + ExpiresAt: time.Now().Add(time.Duration(1) * time.Hour), + AccountID: suite.testAccounts["local_account_1"].ID, + TargetAccountID: suite.testAccounts["local_account_2"].ID, + } + err := suite.db.PutMute(context.Background(), mute1) + if err != nil { + suite.FailNow(err.Error()) + } + + // Mute a user with an indefinite duration. + mute2 := >smodel.UserMute{ + ID: "01HZQ4K641EMWBEJ9A99WST1GP", + AccountID: suite.testAccounts["local_account_1"].ID, + TargetAccountID: suite.testAccounts["remote_account_1"].ID, + } + err = suite.db.PutMute(context.Background(), mute2) + if err != nil { + suite.FailNow(err.Error()) + } + + // Fetch all muted accounts for the logged-in account. + mutedAccounts, err := suite.getMutedAccounts(http.StatusOK, "") + if err != nil { + suite.FailNow(err.Error()) + } + suite.NotEmpty(mutedAccounts) + + // Check that we got the accounts we just muted, and that their mute expiration times are set correctly. + // Note that the account list will be in *reverse* order by mute ID. + if suite.Len(mutedAccounts, 2) { + // This mute expiration should be a string. + mutedAccount1 := mutedAccounts[1] + suite.Equal(mute1.TargetAccountID, mutedAccount1.ID) + suite.NotEmpty(mutedAccount1.MuteExpiresAt) + + // This mute expiration should be null. + mutedAccount2 := mutedAccounts[0] + suite.Equal(mute2.TargetAccountID, mutedAccount2.ID) + suite.Nil(mutedAccount2.MuteExpiresAt) + } +} + +func (suite *MutesTestSuite) TestIndefinitelyMutedAccountSerializesMuteExpirationAsNull() { + // Mute a user with an indefinite duration. + mute := >smodel.UserMute{ + ID: "01HZQ4K641EMWBEJ9A99WST1GP", + AccountID: suite.testAccounts["local_account_1"].ID, + TargetAccountID: suite.testAccounts["remote_account_1"].ID, + } + err := suite.db.PutMute(context.Background(), mute) + if err != nil { + suite.FailNow(err.Error()) + } + + // Fetch all muted accounts for the logged-in account. + // The expected body contains `"mute_expires_at":null`. + _, err = suite.getMutedAccounts(http.StatusOK, `[{"id":"01F8MH5ZK5VRH73AKHQM6Y9VNX","username":"foss_satan","acct":"foss_satan@fossbros-anonymous.io","display_name":"big gerald","locked":false,"discoverable":true,"bot":false,"created_at":"2021-09-26T10:52:36.000Z","note":"i post about like, i dunno, stuff, or whatever!!!!","url":"http://fossbros-anonymous.io/@foss_satan","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":0,"following_count":0,"statuses_count":3,"last_status_at":"2021-09-11T09:40:37.000Z","emojis":[],"fields":[],"mute_expires_at":null}]`) + if err != nil { + suite.FailNow(err.Error()) + } +} diff --git a/internal/api/model/account.go b/internal/api/model/account.go index a2f7b46b6..b3a92d36f 100644 --- a/internal/api/model/account.go +++ b/internal/api/model/account.go @@ -86,9 +86,6 @@ type Account struct { Fields []Field `json:"fields"` // Account has been suspended by our instance. Suspended bool `json:"suspended,omitempty"` - // If this account has been muted, when will the mute expire (ISO 8601 Datetime). - // example: 2021-07-30T09:20:25+00:00 - MuteExpiresAt string `json:"mute_expires_at,omitempty"` // Extra profile information. Shown only if the requester owns the account being requested. Source *Source `json:"source,omitempty"` // Filename of user-selected CSS theme to include when rendering this account's profile or statuses. Eg., `blurple-light.css`. @@ -109,6 +106,17 @@ type Account struct { Moved *Account `json:"moved,omitempty"` } +// MutedAccount extends Account with a field used only by the muted user list. +// +// swagger:model mutedAccount +type MutedAccount struct { + Account + // If this account has been muted, when will the mute expire (ISO 8601 Datetime). + // If the mute is indefinite, this will be null. + // example: 2021-07-30T09:20:25+00:00 + MuteExpiresAt *string `json:"mute_expires_at"` +} + // AccountCreateRequest models account creation parameters. // // swagger:parameters accountCreate diff --git a/internal/api/model/usermute.go b/internal/api/model/usermute.go new file mode 100644 index 000000000..138f837db --- /dev/null +++ b/internal/api/model/usermute.go @@ -0,0 +1,34 @@ +// 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 model + +// UserMuteCreateUpdateRequest captures params for creating or updating a user mute. +// +// swagger:ignore +type UserMuteCreateUpdateRequest struct { + // Should the mute apply to notifications from that user? + // + // Example: true + Notifications *bool `form:"notifications" json:"notifications" xml:"notifications"` + // Number of seconds from now that the mute should expire. If omitted or 0, mute never expires. + Duration *int `json:"-" form:"duration" xml:"duration"` + // Number of seconds from now that the mute should expire. If omitted or 0, mute never expires. + // + // Example: 86400 + DurationI interface{} `json:"duration"` +} diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 2af5e20ca..bb910f3e6 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -94,6 +94,8 @@ func (c *Caches) Init() { c.initToken() c.initTombstone() c.initUser() + c.initUserMute() + c.initUserMuteIDs() c.initWebfinger() c.initVisibility() } @@ -164,5 +166,7 @@ func (c *Caches) Sweep(threshold float64) { c.GTS.Token.Trim(threshold) c.GTS.Tombstone.Trim(threshold) c.GTS.User.Trim(threshold) + c.GTS.UserMute.Trim(threshold) + c.GTS.UserMuteIDs.Trim(threshold) c.Visibility.Trim(threshold) } diff --git a/internal/cache/db.go b/internal/cache/db.go index a5325f6ef..e00c02701 100644 --- a/internal/cache/db.go +++ b/internal/cache/db.go @@ -47,7 +47,7 @@ type GTSCaches struct { // Block provides access to the gtsmodel Block (account) database cache. Block StructCache[*gtsmodel.Block] - // FollowIDs provides access to the block IDs database cache. + // BlockIDs provides access to the block IDs database cache. BlockIDs SliceCache[string] // BoostOfIDs provides access to the boost of IDs list database cache. @@ -166,6 +166,12 @@ type GTSCaches struct { // User provides access to the gtsmodel User database cache. User StructCache[*gtsmodel.User] + // UserMute provides access to the gtsmodel UserMute database cache. + UserMute StructCache[*gtsmodel.UserMute] + + // UserMuteIDs provides access to the user mute IDs database cache. + UserMuteIDs SliceCache[string] + // Webfinger provides access to the webfinger URL cache. // TODO: move out of GTS caches since unrelated to DB. Webfinger *ttl.Cache[string, string] // TTL=24hr, sweep=5min @@ -1347,6 +1353,51 @@ func (c *Caches) initUser() { }) } +func (c *Caches) initUserMute() { + cap := calculateResultCacheMax( + sizeofUserMute(), // model in-mem size. + config.GetCacheUserMuteMemRatio(), + ) + + log.Infof(nil, "cache size = %d", cap) + + copyF := func(u1 *gtsmodel.UserMute) *gtsmodel.UserMute { + u2 := new(gtsmodel.UserMute) + *u2 = *u1 + + // Don't include ptr fields that + // will be populated separately. + // See internal/db/bundb/relationship_mute.go. + u2.Account = nil + u2.TargetAccount = nil + + return u2 + } + + c.GTS.UserMute.Init(structr.CacheConfig[*gtsmodel.UserMute]{ + Indices: []structr.IndexConfig{ + {Fields: "ID"}, + {Fields: "AccountID,TargetAccountID"}, + {Fields: "AccountID", Multiple: true}, + {Fields: "TargetAccountID", Multiple: true}, + }, + MaxSize: cap, + IgnoreErr: ignoreErrors, + Copy: copyF, + Invalidate: c.OnInvalidateUserMute, + }) +} + +func (c *Caches) initUserMuteIDs() { + cap := calculateSliceCacheMax( + config.GetCacheUserMuteIDsMemRatio(), + ) + + log.Infof(nil, "cache size = %d", cap) + + c.GTS.UserMuteIDs.Init(0, cap) +} + func (c *Caches) initWebfinger() { // Calculate maximum cache size. cap := calculateCacheMax( diff --git a/internal/cache/invalidate.go b/internal/cache/invalidate.go index 9c626d7a9..088e7f91f 100644 --- a/internal/cache/invalidate.go +++ b/internal/cache/invalidate.go @@ -213,3 +213,8 @@ func (c *Caches) OnInvalidateUser(user *gtsmodel.User) { c.Visibility.Invalidate("ItemID", user.AccountID) c.Visibility.Invalidate("RequesterID", user.AccountID) } + +func (c *Caches) OnInvalidateUserMute(mute *gtsmodel.UserMute) { + // Invalidate source account's user mute lists. + c.GTS.UserMuteIDs.Invalidate(mute.AccountID) +} diff --git a/internal/cache/size.go b/internal/cache/size.go index e205bf023..e1529f741 100644 --- a/internal/cache/size.go +++ b/internal/cache/size.go @@ -715,3 +715,15 @@ func sizeofUser() uintptr { ExternalID: exampleID, })) } + +func sizeofUserMute() uintptr { + return uintptr(size.Of(>smodel.UserMute{ + ID: exampleID, + CreatedAt: exampleTime, + UpdatedAt: exampleTime, + ExpiresAt: exampleTime, + AccountID: exampleID, + TargetAccountID: exampleID, + Notifications: util.Ptr(false), + })) +} diff --git a/internal/config/config.go b/internal/config/config.go index f738ba797..8d410f6ac 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -198,7 +198,7 @@ type CacheConfiguration struct { AccountStatsMemRatio float64 `name:"account-stats-mem-ratio"` ApplicationMemRatio float64 `name:"application-mem-ratio"` BlockMemRatio float64 `name:"block-mem-ratio"` - BlockIDsMemRatio float64 `name:"block-mem-ratio"` + BlockIDsMemRatio float64 `name:"block-ids-mem-ratio"` BoostOfIDsMemRatio float64 `name:"boost-of-ids-mem-ratio"` ClientMemRatio float64 `name:"client-mem-ratio"` EmojiMemRatio float64 `name:"emoji-mem-ratio"` @@ -233,6 +233,8 @@ type CacheConfiguration struct { TokenMemRatio float64 `name:"token-mem-ratio"` TombstoneMemRatio float64 `name:"tombstone-mem-ratio"` UserMemRatio float64 `name:"user-mem-ratio"` + UserMuteMemRatio float64 `name:"user-mute-mem-ratio"` + UserMuteIDsMemRatio float64 `name:"user-mute-ids-mem-ratio"` WebfingerMemRatio float64 `name:"webfinger-mem-ratio"` VisibilityMemRatio float64 `name:"visibility-mem-ratio"` } diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 3410dc5e4..8a76cc21a 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -197,6 +197,8 @@ var Defaults = Configuration{ TokenMemRatio: 0.75, TombstoneMemRatio: 0.5, UserMemRatio: 0.25, + UserMuteMemRatio: 2, + UserMuteIDsMemRatio: 3, WebfingerMemRatio: 0.1, VisibilityMemRatio: 2, }, diff --git a/internal/config/helpers.gen.go b/internal/config/helpers.gen.go index 2f37cbacb..edfe96e57 100644 --- a/internal/config/helpers.gen.go +++ b/internal/config/helpers.gen.go @@ -2,7 +2,7 @@ // 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 @@ -2917,7 +2917,7 @@ func (st *ConfigState) SetCacheBlockIDsMemRatio(v float64) { } // CacheBlockIDsMemRatioFlag returns the flag name for the 'Cache.BlockIDsMemRatio' field -func CacheBlockIDsMemRatioFlag() string { return "cache-block-mem-ratio" } +func CacheBlockIDsMemRatioFlag() string { return "cache-block-ids-mem-ratio" } // GetCacheBlockIDsMemRatio safely fetches the value for global configuration 'Cache.BlockIDsMemRatio' field func GetCacheBlockIDsMemRatio() float64 { return global.GetCacheBlockIDsMemRatio() } @@ -3775,6 +3775,56 @@ func GetCacheUserMemRatio() float64 { return global.GetCacheUserMemRatio() } // SetCacheUserMemRatio safely sets the value for global configuration 'Cache.UserMemRatio' field func SetCacheUserMemRatio(v float64) { global.SetCacheUserMemRatio(v) } +// GetCacheUserMuteMemRatio safely fetches the Configuration value for state's 'Cache.UserMuteMemRatio' field +func (st *ConfigState) GetCacheUserMuteMemRatio() (v float64) { + st.mutex.RLock() + v = st.config.Cache.UserMuteMemRatio + st.mutex.RUnlock() + return +} + +// SetCacheUserMuteMemRatio safely sets the Configuration value for state's 'Cache.UserMuteMemRatio' field +func (st *ConfigState) SetCacheUserMuteMemRatio(v float64) { + st.mutex.Lock() + defer st.mutex.Unlock() + st.config.Cache.UserMuteMemRatio = v + st.reloadToViper() +} + +// CacheUserMuteMemRatioFlag returns the flag name for the 'Cache.UserMuteMemRatio' field +func CacheUserMuteMemRatioFlag() string { return "cache-user-mute-mem-ratio" } + +// GetCacheUserMuteMemRatio safely fetches the value for global configuration 'Cache.UserMuteMemRatio' field +func GetCacheUserMuteMemRatio() float64 { return global.GetCacheUserMuteMemRatio() } + +// SetCacheUserMuteMemRatio safely sets the value for global configuration 'Cache.UserMuteMemRatio' field +func SetCacheUserMuteMemRatio(v float64) { global.SetCacheUserMuteMemRatio(v) } + +// GetCacheUserMuteIDsMemRatio safely fetches the Configuration value for state's 'Cache.UserMuteIDsMemRatio' field +func (st *ConfigState) GetCacheUserMuteIDsMemRatio() (v float64) { + st.mutex.RLock() + v = st.config.Cache.UserMuteIDsMemRatio + st.mutex.RUnlock() + return +} + +// SetCacheUserMuteIDsMemRatio safely sets the Configuration value for state's 'Cache.UserMuteIDsMemRatio' field +func (st *ConfigState) SetCacheUserMuteIDsMemRatio(v float64) { + st.mutex.Lock() + defer st.mutex.Unlock() + st.config.Cache.UserMuteIDsMemRatio = v + st.reloadToViper() +} + +// CacheUserMuteIDsMemRatioFlag returns the flag name for the 'Cache.UserMuteIDsMemRatio' field +func CacheUserMuteIDsMemRatioFlag() string { return "cache-user-mute-ids-mem-ratio" } + +// GetCacheUserMuteIDsMemRatio safely fetches the value for global configuration 'Cache.UserMuteIDsMemRatio' field +func GetCacheUserMuteIDsMemRatio() float64 { return global.GetCacheUserMuteIDsMemRatio() } + +// SetCacheUserMuteIDsMemRatio safely sets the value for global configuration 'Cache.UserMuteIDsMemRatio' field +func SetCacheUserMuteIDsMemRatio(v float64) { global.SetCacheUserMuteIDsMemRatio(v) } + // GetCacheWebfingerMemRatio safely fetches the Configuration value for state's 'Cache.WebfingerMemRatio' field func (st *ConfigState) GetCacheWebfingerMemRatio() (v float64) { st.mutex.RLock() @@ -4024,3 +4074,4 @@ func GetRequestIDHeader() string { return global.GetRequestIDHeader() } // SetRequestIDHeader safely sets the value for global configuration 'RequestIDHeader' field func SetRequestIDHeader(v string) { global.SetRequestIDHeader(v) } + 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) } diff --git a/internal/filter/usermute/usermute.go b/internal/filter/usermute/usermute.go new file mode 100644 index 000000000..6d710e995 --- /dev/null +++ b/internal/filter/usermute/usermute.go @@ -0,0 +1,80 @@ +// 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 usermute + +import ( + "time" + + statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +type compiledUserMuteListEntry struct { + ExpiresAt time.Time + Notifications bool +} + +func (e *compiledUserMuteListEntry) appliesInContext(filterContext statusfilter.FilterContext) bool { + switch filterContext { + case statusfilter.FilterContextHome: + return true + case statusfilter.FilterContextNotifications: + return e.Notifications + case statusfilter.FilterContextPublic: + return true + case statusfilter.FilterContextThread: + return true + case statusfilter.FilterContextAccount: + return false + } + return false +} + +func (e *compiledUserMuteListEntry) expired(now time.Time) bool { + return !e.ExpiresAt.IsZero() && !e.ExpiresAt.After(now) +} + +type CompiledUserMuteList struct { + byTargetAccountID map[string]compiledUserMuteListEntry +} + +func NewCompiledUserMuteList(mutes []*gtsmodel.UserMute) (c *CompiledUserMuteList) { + c = &CompiledUserMuteList{byTargetAccountID: make(map[string]compiledUserMuteListEntry, len(mutes))} + for _, mute := range mutes { + c.byTargetAccountID[mute.TargetAccountID] = compiledUserMuteListEntry{ + ExpiresAt: mute.ExpiresAt, + Notifications: *mute.Notifications, + } + } + return +} + +func (c *CompiledUserMuteList) Len() int { + if c == nil { + return 0 + } + return len(c.byTargetAccountID) +} + +func (c *CompiledUserMuteList) Matches(accountID string, filterContext statusfilter.FilterContext, now time.Time) bool { + if c == nil { + return false + } + e, found := c.byTargetAccountID[accountID] + return found && e.appliesInContext(filterContext) && !e.expired(now) +} diff --git a/internal/gtsmodel/usermute.go b/internal/gtsmodel/usermute.go new file mode 100644 index 000000000..5ee003d89 --- /dev/null +++ b/internal/gtsmodel/usermute.go @@ -0,0 +1,41 @@ +// 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 gtsmodel + +import ( + "time" +) + +// UserMute refers to the muting of one account by another. +type UserMute struct { + ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database + CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created + UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated + ExpiresAt time.Time `bun:"type:timestamptz,nullzero"` // Time mute should expire. If null, should not expire. + AccountID string `bun:"type:CHAR(26),unique:user_mutes_account_id_target_account_id_uniq,notnull,nullzero"` // Who does this mute originate from? + Account *Account `bun:"-"` // Account corresponding to accountID + TargetAccountID string `bun:"type:CHAR(26),unique:user_mutes_account_id_target_account_id_uniq,notnull,nullzero"` // Who is the target of this mute? + TargetAccount *Account `bun:"-"` // Account corresponding to targetAccountID + Notifications *bool `bun:",nullzero,notnull,default:false"` // Apply mute to notifications as well as statuses. +} + +// Expired returns whether the mute has expired at a given time. +// Mutes without an expiration timestamp never expire. +func (u *UserMute) Expired(now time.Time) bool { + return !u.ExpiresAt.IsZero() && !u.ExpiresAt.After(now) +} diff --git a/internal/processing/account/bookmarks.go b/internal/processing/account/bookmarks.go index 5618934ae..b9ecf0217 100644 --- a/internal/processing/account/bookmarks.go +++ b/internal/processing/account/bookmarks.go @@ -75,7 +75,7 @@ func (p *Processor) BookmarksGet(ctx context.Context, requestingAccount *gtsmode } // Convert the status. - item, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextNone, nil) + item, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextNone, nil, nil) if err != nil { log.Errorf(ctx, "error converting bookmarked status to api: %s", err) continue diff --git a/internal/processing/account/mute.go b/internal/processing/account/mute.go new file mode 100644 index 000000000..00bb9dd22 --- /dev/null +++ b/internal/processing/account/mute.go @@ -0,0 +1,198 @@ +// 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 account + +import ( + "context" + "errors" + "time" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/paging" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +// MuteCreate handles the creation or updating of a mute from requestingAccount to targetAccountID. +// The form params should have already been normalized by the time they reach this function. +func (p *Processor) MuteCreate( + ctx context.Context, + requestingAccount *gtsmodel.Account, + targetAccountID string, + form *apimodel.UserMuteCreateUpdateRequest, +) (*apimodel.Relationship, gtserror.WithCode) { + targetAccount, existingMute, errWithCode := p.getMuteTarget(ctx, requestingAccount, targetAccountID) + if errWithCode != nil { + return nil, errWithCode + } + + if existingMute != nil && + *existingMute.Notifications == *form.Notifications && + existingMute.ExpiresAt.IsZero() && form.Duration == nil { + // Mute already exists and doesn't require updating, nothing to do. + return p.RelationshipGet(ctx, requestingAccount, targetAccountID) + } + + // Create a new mute or update an existing one. + mute := >smodel.UserMute{ + AccountID: requestingAccount.ID, + Account: requestingAccount, + TargetAccountID: targetAccountID, + TargetAccount: targetAccount, + Notifications: form.Notifications, + } + if existingMute != nil { + mute.ID = existingMute.ID + } else { + mute.ID = id.NewULID() + } + if form.Duration != nil { + mute.ExpiresAt = time.Now().Add(time.Second * time.Duration(*form.Duration)) + } + + if err := p.state.DB.PutMute(ctx, mute); err != nil { + err = gtserror.Newf("error creating or updating mute in db: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return p.RelationshipGet(ctx, requestingAccount, targetAccountID) +} + +// MuteRemove handles the removal of a mute from requestingAccount to targetAccountID. +func (p *Processor) MuteRemove( + ctx context.Context, + requestingAccount *gtsmodel.Account, + targetAccountID string, +) (*apimodel.Relationship, gtserror.WithCode) { + _, existingMute, errWithCode := p.getMuteTarget(ctx, requestingAccount, targetAccountID) + if errWithCode != nil { + return nil, errWithCode + } + + if existingMute == nil { + // Already not muted, nothing to do. + return p.RelationshipGet(ctx, requestingAccount, targetAccountID) + } + + // We got a mute, remove it from the db. + if err := p.state.DB.DeleteMuteByID(ctx, existingMute.ID); err != nil { + err := gtserror.Newf("error removing mute from db: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return p.RelationshipGet(ctx, requestingAccount, targetAccountID) +} + +// MutesGet retrieves the user's list of muted accounts, with an extra field for mute expiration (if applicable). +func (p *Processor) MutesGet( + ctx context.Context, + requestingAccount *gtsmodel.Account, + page *paging.Page, +) (*apimodel.PageableResponse, gtserror.WithCode) { + mutes, err := p.state.DB.GetAccountMutes(ctx, + requestingAccount.ID, + page, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err = gtserror.Newf("couldn't list account's mutes: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Check for empty response. + count := len(mutes) + if len(mutes) == 0 { + return util.EmptyPageableResponse(), nil + } + + // Get the lowest and highest + // ID values, used for paging. + lo := mutes[count-1].ID + hi := mutes[0].ID + + items := make([]interface{}, 0, count) + + now := time.Now() + for _, mute := range mutes { + // Skip accounts for which the mute has expired. + if mute.Expired(now) { + continue + } + + // Convert target account to frontend API model. (target will never be nil) + account, err := p.converter.AccountToAPIAccountPublic(ctx, mute.TargetAccount) + if err != nil { + log.Errorf(ctx, "error converting account to public api account: %v", err) + continue + } + mutedAccount := &apimodel.MutedAccount{ + Account: *account, + } + // Add the mute expiration field (unique to this API). + if !mute.ExpiresAt.IsZero() { + mutedAccount.MuteExpiresAt = util.Ptr(util.FormatISO8601(mute.ExpiresAt)) + } + + // Append target to return items. + items = append(items, mutedAccount) + } + + return paging.PackageResponse(paging.ResponseParams{ + Items: items, + Path: "/api/v1/mutes", + Next: page.Next(lo, hi), + Prev: page.Prev(lo, hi), + }), nil +} + +func (p *Processor) getMuteTarget( + ctx context.Context, + requestingAccount *gtsmodel.Account, + targetAccountID string, +) (*gtsmodel.Account, *gtsmodel.UserMute, gtserror.WithCode) { + // Account should not mute or unmute itself. + if requestingAccount.ID == targetAccountID { + err := gtserror.Newf("account %s cannot mute or unmute itself", requestingAccount.ID) + return nil, nil, gtserror.NewErrorNotAcceptable(err, err.Error()) + } + + // Ensure target account retrievable. + targetAccount, err := p.state.DB.GetAccountByID(ctx, targetAccountID) + if err != nil { + if !errors.Is(err, db.ErrNoEntries) { + // Real db error. + err = gtserror.Newf("db error looking for target account %s: %w", targetAccountID, err) + return nil, nil, gtserror.NewErrorInternalError(err) + } + // Account not found. + err = gtserror.Newf("target account %s not found in the db", targetAccountID) + return nil, nil, gtserror.NewErrorNotFound(err, err.Error()) + } + + // Check if currently muted. + mute, err := p.state.DB.GetMute(ctx, requestingAccount.ID, targetAccountID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err = gtserror.Newf("db error checking existing mute: %w", err) + return nil, nil, gtserror.NewErrorInternalError(err) + } + + return targetAccount, mute, nil +} diff --git a/internal/processing/account/statuses.go b/internal/processing/account/statuses.go index 8f0548371..2513f17c7 100644 --- a/internal/processing/account/statuses.go +++ b/internal/processing/account/statuses.go @@ -105,7 +105,7 @@ func (p *Processor) StatusesGet( for _, s := range filtered { // Convert filtered statuses to API statuses. - item, err := p.converter.StatusToAPIStatus(ctx, s, requestingAccount, statusfilter.FilterContextAccount, filters) + item, err := p.converter.StatusToAPIStatus(ctx, s, requestingAccount, statusfilter.FilterContextAccount, filters, nil) if err != nil { log.Errorf(ctx, "error convering to api status: %v", err) continue diff --git a/internal/processing/common/status.go b/internal/processing/common/status.go index bb46ee38c..2ffc90035 100644 --- a/internal/processing/common/status.go +++ b/internal/processing/common/status.go @@ -185,7 +185,7 @@ func (p *Processor) GetAPIStatus( apiStatus *apimodel.Status, errWithCode gtserror.WithCode, ) { - apiStatus, err := p.converter.StatusToAPIStatus(ctx, target, requester, statusfilter.FilterContextNone, nil) + apiStatus, err := p.converter.StatusToAPIStatus(ctx, target, requester, statusfilter.FilterContextNone, nil, nil) if err != nil { err = gtserror.Newf("error converting status: %w", err) return nil, gtserror.NewErrorInternalError(err) diff --git a/internal/processing/search/util.go b/internal/processing/search/util.go index 196fef5fc..190289155 100644 --- a/internal/processing/search/util.go +++ b/internal/processing/search/util.go @@ -114,7 +114,7 @@ func (p *Processor) packageStatuses( continue } - apiStatus, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextNone, nil) + apiStatus, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextNone, nil, nil) if err != nil { log.Debugf(ctx, "skipping status %s because it couldn't be converted to its api representation: %s", status.ID, err) continue diff --git a/internal/processing/status/get.go b/internal/processing/status/get.go index c05f3effd..16f55b439 100644 --- a/internal/processing/status/get.go +++ b/internal/processing/status/get.go @@ -24,6 +24,8 @@ import ( apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status" + "github.com/superseriousbusiness/gotosocial/internal/filter/usermute" + "github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/util" @@ -286,8 +288,16 @@ func (p *Processor) ContextGet(ctx context.Context, requestingAccount *gtsmodel. err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requestingAccount.ID, err) return nil, gtserror.NewErrorInternalError(err) } + + mutes, err := p.state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), requestingAccount.ID, nil) + if err != nil { + err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", requestingAccount.ID, err) + return nil, gtserror.NewErrorInternalError(err) + } + compiledMutes := usermute.NewCompiledUserMuteList(mutes) + convert := func(ctx context.Context, status *gtsmodel.Status, requestingAccount *gtsmodel.Account) (*apimodel.Status, error) { - return p.converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextThread, filters) + return p.converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextThread, filters, compiledMutes) } return p.contextGet(ctx, requestingAccount, targetStatusID, convert) } diff --git a/internal/processing/stream/statusupdate_test.go b/internal/processing/stream/statusupdate_test.go index 12971caa1..359212ee6 100644 --- a/internal/processing/stream/statusupdate_test.go +++ b/internal/processing/stream/statusupdate_test.go @@ -40,7 +40,7 @@ func (suite *StatusUpdateTestSuite) TestStreamNotification() { suite.NoError(errWithCode) editedStatus := suite.testStatuses["remote_account_1_status_1"] - apiStatus, err := typeutils.NewConverter(&suite.state).StatusToAPIStatus(context.Background(), editedStatus, account, statusfilter.FilterContextNotifications, nil) + apiStatus, err := typeutils.NewConverter(&suite.state).StatusToAPIStatus(context.Background(), editedStatus, account, statusfilter.FilterContextNotifications, nil, nil) suite.NoError(err) suite.streamProcessor.StatusUpdate(context.Background(), account, apiStatus, stream.TimelineHome) diff --git a/internal/processing/timeline/faved.go b/internal/processing/timeline/faved.go index c3b0e1837..cd3729465 100644 --- a/internal/processing/timeline/faved.go +++ b/internal/processing/timeline/faved.go @@ -55,7 +55,7 @@ func (p *Processor) FavedTimelineGet(ctx context.Context, authed *oauth.Auth, ma continue } - apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, authed.Account, statusfilter.FilterContextNone, nil) + apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, authed.Account, statusfilter.FilterContextNone, nil, nil) if err != nil { log.Errorf(ctx, "error convering to api status: %v", err) continue diff --git a/internal/processing/timeline/home.go b/internal/processing/timeline/home.go index e174b3428..8bf8dd428 100644 --- a/internal/processing/timeline/home.go +++ b/internal/processing/timeline/home.go @@ -24,7 +24,9 @@ import ( apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status" + "github.com/superseriousbusiness/gotosocial/internal/filter/usermute" "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" + "github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/oauth" @@ -105,7 +107,14 @@ func HomeTimelineStatusPrepare(state *state.State, converter *typeutils.Converte return nil, err } - return converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextHome, filters) + mutes, err := state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), requestingAccount.ID, nil) + if err != nil { + err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", requestingAccount.ID, err) + return nil, err + } + compiledMutes := usermute.NewCompiledUserMuteList(mutes) + + return converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextHome, filters, compiledMutes) } } diff --git a/internal/processing/timeline/list.go b/internal/processing/timeline/list.go index 60cdbac7a..2065256e3 100644 --- a/internal/processing/timeline/list.go +++ b/internal/processing/timeline/list.go @@ -24,7 +24,9 @@ import ( apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status" + "github.com/superseriousbusiness/gotosocial/internal/filter/usermute" "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" + "github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/oauth" @@ -117,7 +119,14 @@ func ListTimelineStatusPrepare(state *state.State, converter *typeutils.Converte return nil, err } - return converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextHome, filters) + mutes, err := state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), requestingAccount.ID, nil) + if err != nil { + err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", requestingAccount.ID, err) + return nil, err + } + compiledMutes := usermute.NewCompiledUserMuteList(mutes) + + return converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextHome, filters, compiledMutes) } } diff --git a/internal/processing/timeline/notification.go b/internal/processing/timeline/notification.go index f99664d62..5156a1cdf 100644 --- a/internal/processing/timeline/notification.go +++ b/internal/processing/timeline/notification.go @@ -24,6 +24,9 @@ import ( apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/filter/status" + "github.com/superseriousbusiness/gotosocial/internal/filter/usermute" + "github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" @@ -49,6 +52,13 @@ func (p *Processor) NotificationsGet(ctx context.Context, authed *oauth.Auth, ma return nil, gtserror.NewErrorInternalError(err) } + mutes, err := p.state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), authed.Account.ID, nil) + if err != nil { + err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", authed.Account.ID, err) + return nil, gtserror.NewErrorInternalError(err) + } + compiledMutes := usermute.NewCompiledUserMuteList(mutes) + var ( items = make([]interface{}, 0, count) nextMaxIDValue string @@ -76,9 +86,11 @@ func (p *Processor) NotificationsGet(ctx context.Context, authed *oauth.Auth, ma continue } - item, err := p.converter.NotificationToAPINotification(ctx, n, filters) + item, err := p.converter.NotificationToAPINotification(ctx, n, filters, compiledMutes) if err != nil { - log.Debugf(ctx, "skipping notification %s because it couldn't be converted to its api representation: %s", n.ID, err) + if !errors.Is(err, status.ErrHideStatus) { + log.Debugf(ctx, "skipping notification %s because it couldn't be converted to its api representation: %s", n.ID, err) + } continue } @@ -116,7 +128,14 @@ func (p *Processor) NotificationGet(ctx context.Context, account *gtsmodel.Accou return nil, gtserror.NewErrorInternalError(err) } - apiNotif, err := p.converter.NotificationToAPINotification(ctx, notif, filters) + mutes, err := p.state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), account.ID, nil) + if err != nil { + err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", account.ID, err) + return nil, gtserror.NewErrorInternalError(err) + } + compiledMutes := usermute.NewCompiledUserMuteList(mutes) + + apiNotif, err := p.converter.NotificationToAPINotification(ctx, notif, filters, compiledMutes) if err != nil { if errors.Is(err, db.ErrNoEntries) { return nil, gtserror.NewErrorNotFound(err) diff --git a/internal/processing/timeline/public.go b/internal/processing/timeline/public.go index a0e594629..28062fb2e 100644 --- a/internal/processing/timeline/public.go +++ b/internal/processing/timeline/public.go @@ -25,6 +25,8 @@ import ( apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status" + "github.com/superseriousbusiness/gotosocial/internal/filter/usermute" + "github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" @@ -48,6 +50,7 @@ func (p *Processor) PublicTimelineGet( ) var filters []*gtsmodel.Filter + var compiledMutes *usermute.CompiledUserMuteList if requester != nil { var err error filters, err = p.state.DB.GetFiltersForAccountID(ctx, requester.ID) @@ -55,6 +58,13 @@ func (p *Processor) PublicTimelineGet( err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requester.ID, err) return nil, gtserror.NewErrorInternalError(err) } + + mutes, err := p.state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), requester.ID, nil) + if err != nil { + err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", requester.ID, err) + return nil, gtserror.NewErrorInternalError(err) + } + compiledMutes = usermute.NewCompiledUserMuteList(mutes) } // Try a few times to select appropriate public @@ -98,7 +108,7 @@ outer: continue inner } - apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, requester, statusfilter.FilterContextPublic, filters) + apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, requester, statusfilter.FilterContextPublic, filters, compiledMutes) if errors.Is(err, statusfilter.ErrHideStatus) { continue } diff --git a/internal/processing/timeline/tag.go b/internal/processing/timeline/tag.go index 5308cac59..4320f6adc 100644 --- a/internal/processing/timeline/tag.go +++ b/internal/processing/timeline/tag.go @@ -25,6 +25,8 @@ import ( apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status" + "github.com/superseriousbusiness/gotosocial/internal/filter/usermute" + "github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" @@ -118,6 +120,13 @@ func (p *Processor) packageTagResponse( return nil, gtserror.NewErrorInternalError(err) } + mutes, err := p.state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), requestingAcct.ID, nil) + if err != nil { + err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", requestingAcct.ID, err) + return nil, gtserror.NewErrorInternalError(err) + } + compiledMutes := usermute.NewCompiledUserMuteList(mutes) + for _, s := range statuses { timelineable, err := p.filter.StatusTagTimelineable(ctx, requestingAcct, s) if err != nil { @@ -129,7 +138,7 @@ func (p *Processor) packageTagResponse( continue } - apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, requestingAcct, statusfilter.FilterContextPublic, filters) + apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, requestingAcct, statusfilter.FilterContextPublic, filters, compiledMutes) if errors.Is(err, statusfilter.ErrHideStatus) { continue } diff --git a/internal/processing/workers/fromclientapi_test.go b/internal/processing/workers/fromclientapi_test.go index 6a12ce043..15be23baf 100644 --- a/internal/processing/workers/fromclientapi_test.go +++ b/internal/processing/workers/fromclientapi_test.go @@ -157,6 +157,7 @@ func (suite *FromClientAPITestSuite) statusJSON( requestingAccount, statusfilter.FilterContextNone, nil, + nil, ) if err != nil { suite.FailNow(err.Error()) @@ -261,7 +262,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithNotification() { suite.FailNow("timed out waiting for new status notification") } - apiNotif, err := testStructs.TypeConverter.NotificationToAPINotification(ctx, notif, nil) + apiNotif, err := testStructs.TypeConverter.NotificationToAPINotification(ctx, notif, nil, nil) if err != nil { suite.FailNow(err.Error()) } diff --git a/internal/processing/workers/surfacenotify.go b/internal/processing/workers/surfacenotify.go index a31946cc8..edeb4b57e 100644 --- a/internal/processing/workers/surfacenotify.go +++ b/internal/processing/workers/surfacenotify.go @@ -23,6 +23,8 @@ import ( "strings" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/filter/status" + "github.com/superseriousbusiness/gotosocial/internal/filter/usermute" "github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" @@ -472,8 +474,17 @@ func (s *Surface) Notify( return gtserror.Newf("couldn't retrieve filters for account %s: %w", targetAccount.ID, err) } - apiNotif, err := s.Converter.NotificationToAPINotification(ctx, notif, filters) + mutes, err := s.State.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), targetAccount.ID, nil) if err != nil { + return gtserror.Newf("couldn't retrieve mutes for account %s: %w", targetAccount.ID, err) + } + compiledMutes := usermute.NewCompiledUserMuteList(mutes) + + apiNotif, err := s.Converter.NotificationToAPINotification(ctx, notif, filters, compiledMutes) + if err != nil { + if errors.Is(err, status.ErrHideStatus) { + return nil + } return gtserror.Newf("error converting notification to api representation: %w", err) } s.Stream.Notify(ctx, targetAccount, apiNotif) diff --git a/internal/processing/workers/surfacetimeline.go b/internal/processing/workers/surfacetimeline.go index 32fdd66e2..41d7f6f2a 100644 --- a/internal/processing/workers/surfacetimeline.go +++ b/internal/processing/workers/surfacetimeline.go @@ -23,6 +23,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status" + "github.com/superseriousbusiness/gotosocial/internal/filter/usermute" "github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" @@ -117,6 +118,12 @@ func (s *Surface) timelineAndNotifyStatusForFollowers( return gtserror.Newf("couldn't retrieve filters for account %s: %w", follow.AccountID, err) } + mutes, err := s.State.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), follow.AccountID, nil) + if err != nil { + return gtserror.Newf("couldn't retrieve mutes for account %s: %w", follow.AccountID, err) + } + compiledMutes := usermute.NewCompiledUserMuteList(mutes) + // Add status to any relevant lists // for this follow, if applicable. s.listTimelineStatusForFollow( @@ -125,6 +132,7 @@ func (s *Surface) timelineAndNotifyStatusForFollowers( follow, &errs, filters, + compiledMutes, ) // Add status to home timeline for owner @@ -137,6 +145,7 @@ func (s *Surface) timelineAndNotifyStatusForFollowers( status, stream.TimelineHome, filters, + compiledMutes, ) if err != nil { errs.Appendf("error home timelining status: %w", err) @@ -189,6 +198,7 @@ func (s *Surface) listTimelineStatusForFollow( follow *gtsmodel.Follow, errs *gtserror.MultiError, filters []*gtsmodel.Filter, + mutes *usermute.CompiledUserMuteList, ) { // To put this status in appropriate list timelines, // we need to get each listEntry that pertains to @@ -232,6 +242,7 @@ func (s *Surface) listTimelineStatusForFollow( status, stream.TimelineList+":"+listEntry.ListID, // key streamType to this specific list filters, + mutes, ); err != nil { errs.Appendf("error adding status to timeline for list %s: %w", listEntry.ListID, err) // implicit continue @@ -343,6 +354,7 @@ func (s *Surface) timelineStatus( status *gtsmodel.Status, streamType string, filters []*gtsmodel.Filter, + mutes *usermute.CompiledUserMuteList, ) (bool, error) { // Ingest status into given timeline using provided function. if inserted, err := ingest(ctx, timelineID, status); err != nil { @@ -359,6 +371,7 @@ func (s *Surface) timelineStatus( account, statusfilter.FilterContextHome, filters, + mutes, ) if err != nil { err = gtserror.Newf("error converting status %s to frontend representation: %w", status.ID, err) @@ -478,6 +491,12 @@ func (s *Surface) timelineStatusUpdateForFollowers( return gtserror.Newf("couldn't retrieve filters for account %s: %w", follow.AccountID, err) } + mutes, err := s.State.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), follow.AccountID, nil) + if err != nil { + return gtserror.Newf("couldn't retrieve mutes for account %s: %w", follow.AccountID, err) + } + compiledMutes := usermute.NewCompiledUserMuteList(mutes) + // Add status to any relevant lists // for this follow, if applicable. s.listTimelineStatusUpdateForFollow( @@ -486,6 +505,7 @@ func (s *Surface) timelineStatusUpdateForFollowers( follow, &errs, filters, + compiledMutes, ) // Add status to home timeline for owner @@ -496,6 +516,7 @@ func (s *Surface) timelineStatusUpdateForFollowers( status, stream.TimelineHome, filters, + compiledMutes, ) if err != nil { errs.Appendf("error home timelining status: %w", err) @@ -514,6 +535,7 @@ func (s *Surface) listTimelineStatusUpdateForFollow( follow *gtsmodel.Follow, errs *gtserror.MultiError, filters []*gtsmodel.Filter, + mutes *usermute.CompiledUserMuteList, ) { // To put this status in appropriate list timelines, // we need to get each listEntry that pertains to @@ -555,6 +577,7 @@ func (s *Surface) listTimelineStatusUpdateForFollow( status, stream.TimelineList+":"+listEntry.ListID, // key streamType to this specific list filters, + mutes, ); err != nil { errs.Appendf("error adding status to timeline for list %s: %w", listEntry.ListID, err) // implicit continue @@ -570,8 +593,9 @@ func (s *Surface) timelineStreamStatusUpdate( status *gtsmodel.Status, streamType string, filters []*gtsmodel.Filter, + mutes *usermute.CompiledUserMuteList, ) error { - apiStatus, err := s.Converter.StatusToAPIStatus(ctx, status, account, statusfilter.FilterContextHome, filters) + apiStatus, err := s.Converter.StatusToAPIStatus(ctx, status, account, statusfilter.FilterContextHome, filters, mutes) if errors.Is(err, statusfilter.ErrHideStatus) { // Don't put this status in the stream. return nil diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go index 5f849c39d..dfa72fdcd 100644 --- a/internal/typeutils/converter.go +++ b/internal/typeutils/converter.go @@ -20,6 +20,7 @@ package typeutils import ( "sync" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/state" ) @@ -27,11 +28,13 @@ type Converter struct { state *state.State defaultAvatars []string randAvatars sync.Map + filter *visibility.Filter } func NewConverter(state *state.State) *Converter { return &Converter{ state: state, defaultAvatars: populateDefaultAvatars(), + filter: visibility.NewFilter(state), } } diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index e1380fc9e..787d8f099 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -31,6 +31,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status" + "github.com/superseriousbusiness/gotosocial/internal/filter/usermute" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/language" @@ -741,8 +742,9 @@ func (c *Converter) StatusToAPIStatus( requestingAccount *gtsmodel.Account, filterContext statusfilter.FilterContext, filters []*gtsmodel.Filter, + mutes *usermute.CompiledUserMuteList, ) (*apimodel.Status, error) { - apiStatus, err := c.statusToFrontend(ctx, s, requestingAccount, filterContext, filters) + apiStatus, err := c.statusToFrontend(ctx, s, requestingAccount, filterContext, filters, mutes) if err != nil { return nil, err } @@ -757,7 +759,7 @@ func (c *Converter) StatusToAPIStatus( return apiStatus, nil } -// statusToAPIFilterResults applies filters to a status and returns an API filter result object. +// statusToAPIFilterResults applies filters and mutes to a status and returns an API filter result object. // The result may be nil if no filters matched. // If the status should not be returned at all, it returns the ErrHideStatus error. func (c *Converter) statusToAPIFilterResults( @@ -766,14 +768,71 @@ func (c *Converter) statusToAPIFilterResults( requestingAccount *gtsmodel.Account, filterContext statusfilter.FilterContext, filters []*gtsmodel.Filter, + mutes *usermute.CompiledUserMuteList, ) ([]apimodel.FilterResult, error) { - if filterContext == "" || len(filters) == 0 || s.AccountID == requestingAccount.ID { + // If there are no filters or mutes, we're done. + // We never hide statuses authored by the requesting account, + // since not being able to see your own posts is confusing. + if filterContext == "" || (len(filters) == 0 && mutes.Len() == 0) || s.AccountID == requestingAccount.ID { return nil, nil } - filterResults := make([]apimodel.FilterResult, 0, len(filters)) - + // Both mutes and filters can expire. now := time.Now() + + // If the requesting account mutes the account that created this status, hide the status. + if mutes.Matches(s.AccountID, filterContext, now) { + return nil, statusfilter.ErrHideStatus + } + // If this status is part of a multi-account discussion, + // and all of the accounts replied to or mentioned are invisible to the requesting account + // (due to blocks, domain blocks, moderation, etc.), + // or are muted, hide the status. + // First, collect the accounts we have to check. + otherAccounts := make([]*gtsmodel.Account, 0, 1+len(s.Mentions)) + if s.InReplyToAccount != nil { + otherAccounts = append(otherAccounts, s.InReplyToAccount) + } + for _, mention := range s.Mentions { + otherAccounts = append(otherAccounts, mention.TargetAccount) + } + // If there are no other accounts, skip this check. + if len(otherAccounts) > 0 { + // Start by assuming that they're all invisible or muted. + allOtherAccountsInvisibleOrMuted := true + + for _, account := range otherAccounts { + // Is this account visible? + visible, err := c.filter.AccountVisible(ctx, requestingAccount, account) + if err != nil { + return nil, err + } + if !visible { + // It's invisible. Check the next account. + continue + } + + // If visible, is it muted? + if mutes.Matches(account.ID, filterContext, now) { + // It's muted. Check the next account. + continue + } + + // If we get here, the account is visible and not muted. + // We should show this status, and don't have to check any more accounts. + allOtherAccountsInvisibleOrMuted = false + break + } + + // If we didn't find any visible non-muted accounts, hide the status. + if allOtherAccountsInvisibleOrMuted { + return nil, statusfilter.ErrHideStatus + } + } + + // At this point, the status isn't muted, but might still be filtered. + // Record all matching warn filters and the reasons they matched. + filterResults := make([]apimodel.FilterResult, 0, len(filters)) for _, filter := range filters { if !filterAppliesInContext(filter, filterContext) { // Filter doesn't apply to this context. @@ -893,7 +952,7 @@ func (c *Converter) StatusToWebStatus( s *gtsmodel.Status, requestingAccount *gtsmodel.Account, ) (*apimodel.Status, error) { - webStatus, err := c.statusToFrontend(ctx, s, requestingAccount, statusfilter.FilterContextNone, nil) + webStatus, err := c.statusToFrontend(ctx, s, requestingAccount, statusfilter.FilterContextNone, nil, nil) if err != nil { return nil, err } @@ -997,6 +1056,7 @@ func (c *Converter) statusToFrontend( requestingAccount *gtsmodel.Account, filterContext statusfilter.FilterContext, filters []*gtsmodel.Filter, + mutes *usermute.CompiledUserMuteList, ) (*apimodel.Status, error) { // Try to populate status struct pointer fields. // We can continue in many cases of partial failure, @@ -1095,7 +1155,7 @@ func (c *Converter) statusToFrontend( } if s.BoostOf != nil { - reblog, err := c.StatusToAPIStatus(ctx, s.BoostOf, requestingAccount, filterContext, filters) + reblog, err := c.StatusToAPIStatus(ctx, s.BoostOf, requestingAccount, filterContext, filters, mutes) if errors.Is(err, statusfilter.ErrHideStatus) { // If we'd hide the original status, hide the boost. return nil, err @@ -1164,8 +1224,11 @@ func (c *Converter) statusToFrontend( } // Apply filters. - filterResults, err := c.statusToAPIFilterResults(ctx, s, requestingAccount, filterContext, filters) + filterResults, err := c.statusToAPIFilterResults(ctx, s, requestingAccount, filterContext, filters, mutes) if err != nil { + if errors.Is(err, statusfilter.ErrHideStatus) { + return nil, err + } return nil, fmt.Errorf("error applying filters: %w", err) } apiStatus.Filtered = filterResults @@ -1453,7 +1516,12 @@ func (c *Converter) RelationshipToAPIRelationship(ctx context.Context, r *gtsmod } // NotificationToAPINotification converts a gts notification into a api notification -func (c *Converter) NotificationToAPINotification(ctx context.Context, n *gtsmodel.Notification, filters []*gtsmodel.Filter) (*apimodel.Notification, error) { +func (c *Converter) NotificationToAPINotification( + ctx context.Context, + n *gtsmodel.Notification, + filters []*gtsmodel.Filter, + mutes *usermute.CompiledUserMuteList, +) (*apimodel.Notification, error) { if n.TargetAccount == nil { tAccount, err := c.state.DB.GetAccountByID(ctx, n.TargetAccountID) if err != nil { @@ -1494,8 +1562,11 @@ func (c *Converter) NotificationToAPINotification(ctx context.Context, n *gtsmod } var err error - apiStatus, err = c.StatusToAPIStatus(ctx, n.Status, n.TargetAccount, statusfilter.FilterContextNotifications, filters) + apiStatus, err = c.StatusToAPIStatus(ctx, n.Status, n.TargetAccount, statusfilter.FilterContextNotifications, filters, mutes) if err != nil { + if errors.Is(err, statusfilter.ErrHideStatus) { + return nil, err + } return nil, fmt.Errorf("NotificationToapi: error converting status to api: %s", err) } } @@ -1647,7 +1718,7 @@ func (c *Converter) ReportToAdminAPIReport(ctx context.Context, r *gtsmodel.Repo } } for _, s := range r.Statuses { - status, err := c.StatusToAPIStatus(ctx, s, requestingAccount, statusfilter.FilterContextNone, nil) + status, err := c.StatusToAPIStatus(ctx, s, requestingAccount, statusfilter.FilterContextNone, nil, nil) if err != nil { return nil, fmt.Errorf("ReportToAdminAPIReport: error converting status with id %s to api status: %w", s.ID, err) } diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go index 946e38b30..16dc27c87 100644 --- a/internal/typeutils/internaltofrontend_test.go +++ b/internal/typeutils/internaltofrontend_test.go @@ -26,7 +26,9 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status" + "github.com/superseriousbusiness/gotosocial/internal/filter/usermute" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/util" "github.com/superseriousbusiness/gotosocial/testrig" ) @@ -428,7 +430,7 @@ func (suite *InternalToFrontendTestSuite) TestLocalInstanceAccountToFrontendBloc func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() { testStatus := suite.testStatuses["admin_account_status_1"] requestingAccount := suite.testAccounts["local_account_1"] - apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount, statusfilter.FilterContextNone, nil) + apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount, statusfilter.FilterContextNone, nil, nil) suite.NoError(err) b, err := json.MarshalIndent(apiStatus, "", " ") @@ -556,6 +558,7 @@ func (suite *InternalToFrontendTestSuite) TestWarnFilteredStatusToFrontend() { requestingAccount, statusfilter.FilterContextHome, requestingAccountFilters, + nil, ) suite.NoError(err) @@ -711,6 +714,60 @@ func (suite *InternalToFrontendTestSuite) TestHideFilteredStatusToFrontend() { requestingAccount, statusfilter.FilterContextHome, requestingAccountFilters, + nil, + ) + suite.ErrorIs(err, statusfilter.ErrHideStatus) +} + +// Test that a status from a user muted by the requesting user results in the ErrHideStatus error. +func (suite *InternalToFrontendTestSuite) TestMutedStatusToFrontend() { + testStatus := suite.testStatuses["admin_account_status_1"] + requestingAccount := suite.testAccounts["local_account_1"] + mutes := usermute.NewCompiledUserMuteList([]*gtsmodel.UserMute{ + { + AccountID: requestingAccount.ID, + TargetAccountID: testStatus.AccountID, + Notifications: util.Ptr(false), + }, + }) + _, err := suite.typeconverter.StatusToAPIStatus( + context.Background(), + testStatus, + requestingAccount, + statusfilter.FilterContextHome, + nil, + mutes, + ) + suite.ErrorIs(err, statusfilter.ErrHideStatus) +} + +// Test that a status replying to a user muted by the requesting user results in the ErrHideStatus error. +func (suite *InternalToFrontendTestSuite) TestMutedReplyStatusToFrontend() { + mutedAccount := suite.testAccounts["local_account_2"] + testStatus := suite.testStatuses["admin_account_status_1"] + testStatus.InReplyToID = suite.testStatuses["local_account_2_status_1"].ID + testStatus.InReplyToAccountID = mutedAccount.ID + requestingAccount := suite.testAccounts["local_account_1"] + mutes := usermute.NewCompiledUserMuteList([]*gtsmodel.UserMute{ + { + AccountID: requestingAccount.ID, + TargetAccountID: mutedAccount.ID, + Notifications: util.Ptr(false), + }, + }) + // Populate status so the converter has the account objects it needs for muting. + err := suite.db.PopulateStatus(context.Background(), testStatus) + if err != nil { + suite.FailNow(err.Error()) + } + // Convert the status to API format, which should fail. + _, err = suite.typeconverter.StatusToAPIStatus( + context.Background(), + testStatus, + requestingAccount, + statusfilter.FilterContextHome, + nil, + mutes, ) suite.ErrorIs(err, statusfilter.ErrHideStatus) } @@ -719,7 +776,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownAttachments testStatus := suite.testStatuses["remote_account_2_status_1"] requestingAccount := suite.testAccounts["admin_account"] - apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount, statusfilter.FilterContextNone, nil) + apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount, statusfilter.FilterContextNone, nil, nil) suite.NoError(err) b, err := json.MarshalIndent(apiStatus, "", " ") @@ -952,7 +1009,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownLanguage() *testStatus = *suite.testStatuses["admin_account_status_1"] testStatus.Language = "" requestingAccount := suite.testAccounts["local_account_1"] - apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount, statusfilter.FilterContextNone, nil) + apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount, statusfilter.FilterContextNone, nil, nil) suite.NoError(err) b, err := json.MarshalIndent(apiStatus, "", " ") |