From 5e2d4fdb19eb4fcd4c0bbfb3e2f29067a58c88c8 Mon Sep 17 00:00:00 2001 From: Vyr Cossont Date: Thu, 6 Jun 2024 09:38:02 -0700 Subject: [PATCH] [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 --- docs/api/swagger.yaml | 240 +++++++++++++- internal/api/client/accounts/accounts.go | 6 + internal/api/client/accounts/mute.go | 170 ++++++++++ internal/api/client/accounts/mute_test.go | 173 ++++++++++ internal/api/client/accounts/unmute.go | 98 ++++++ internal/api/client/accounts/unmute_test.go | 136 ++++++++ internal/api/client/mutes/mutes_test.go | 136 ++++++++ internal/api/client/mutes/mutesget.go | 35 +- internal/api/client/mutes/mutesget_test.go | 155 +++++++++ internal/api/model/account.go | 14 +- internal/api/model/usermute.go | 34 ++ internal/cache/cache.go | 4 + internal/cache/db.go | 53 ++- internal/cache/invalidate.go | 5 + internal/cache/size.go | 12 + internal/config/config.go | 4 +- internal/config/defaults.go | 2 + internal/config/helpers.gen.go | 55 +++- .../20240528071620_add_user_mutes.go | 61 ++++ internal/db/bundb/relationship.go | 11 + internal/db/bundb/relationship_mute.go | 306 ++++++++++++++++++ internal/db/bundb/relationship_test.go | 37 +++ internal/db/relationship.go | 21 ++ internal/filter/usermute/usermute.go | 80 +++++ internal/gtsmodel/usermute.go | 41 +++ internal/processing/account/bookmarks.go | 2 +- internal/processing/account/mute.go | 198 ++++++++++++ internal/processing/account/statuses.go | 2 +- internal/processing/common/status.go | 2 +- internal/processing/search/util.go | 2 +- internal/processing/status/get.go | 12 +- .../processing/stream/statusupdate_test.go | 2 +- internal/processing/timeline/faved.go | 2 +- internal/processing/timeline/home.go | 11 +- internal/processing/timeline/list.go | 11 +- internal/processing/timeline/notification.go | 25 +- internal/processing/timeline/public.go | 12 +- internal/processing/timeline/tag.go | 11 +- .../processing/workers/fromclientapi_test.go | 3 +- internal/processing/workers/surfacenotify.go | 13 +- .../processing/workers/surfacetimeline.go | 26 +- internal/typeutils/converter.go | 3 + internal/typeutils/internaltofrontend.go | 93 +++++- internal/typeutils/internaltofrontend_test.go | 63 +++- test/envparsing.sh | 5 +- testrig/db.go | 7 + testrig/testmodels.go | 5 + 47 files changed, 2346 insertions(+), 53 deletions(-) create mode 100644 internal/api/client/accounts/mute.go create mode 100644 internal/api/client/accounts/mute_test.go create mode 100644 internal/api/client/accounts/unmute.go create mode 100644 internal/api/client/accounts/unmute_test.go create mode 100644 internal/api/client/mutes/mutes_test.go create mode 100644 internal/api/client/mutes/mutesget_test.go create mode 100644 internal/api/model/usermute.go create mode 100644 internal/db/bundb/migrations/20240528071620_add_user_mutes.go create mode 100644 internal/db/bundb/relationship_mute.go create mode 100644 internal/filter/usermute/usermute.go create mode 100644 internal/gtsmodel/usermute.go create mode 100644 internal/processing/account/mute.go diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index 55e88b62c..05f127d55 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -288,11 +288,6 @@ definitions: x-go-name: Locked moved: $ref: '#/definitions/account' - mute_expires_at: - description: If this account has been muted, when will the mute expire (ISO 8601 Datetime). - example: "2021-07-30T09:20:25+00:00" - type: string - x-go-name: MuteExpiresAt note: description: Bio/description of this account. type: string @@ -1931,6 +1926,157 @@ definitions: type: object x-go-name: MediaMeta x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model + mutedAccount: + properties: + acct: + description: |- + The account URI as discovered via webfinger. + Equal to username for local users, or username@domain for remote users. + example: some_user@example.org + type: string + x-go-name: Acct + avatar: + description: Web location of the account's avatar. + example: https://example.org/media/some_user/avatar/original/avatar.jpeg + type: string + x-go-name: Avatar + avatar_static: + description: |- + Web location of a static version of the account's avatar. + Only relevant when the account's main avatar is a video or a gif. + example: https://example.org/media/some_user/avatar/static/avatar.png + type: string + x-go-name: AvatarStatic + bot: + description: Account identifies as a bot. + type: boolean + x-go-name: Bot + created_at: + description: When the account was created (ISO 8601 Datetime). + example: "2021-07-30T09:20:25+00:00" + type: string + x-go-name: CreatedAt + custom_css: + description: CustomCSS to include when rendering this account's profile or statuses. + type: string + x-go-name: CustomCSS + discoverable: + description: Account has opted into discovery features. + type: boolean + x-go-name: Discoverable + display_name: + description: The account's display name. + example: big jeff (he/him) + type: string + x-go-name: DisplayName + emojis: + description: |- + Array of custom emojis used in this account's note or display name. + Empty for blocked accounts. + items: + $ref: '#/definitions/emoji' + type: array + x-go-name: Emojis + enable_rss: + description: |- + Account has enabled RSS feed. + Key/value omitted if false. + type: boolean + x-go-name: EnableRSS + fields: + description: |- + Additional metadata attached to this account's profile. + Empty for blocked accounts. + items: + $ref: '#/definitions/field' + type: array + x-go-name: Fields + followers_count: + description: Number of accounts following this account, according to our instance. + format: int64 + type: integer + x-go-name: FollowersCount + following_count: + description: Number of account's followed by this account, according to our instance. + format: int64 + type: integer + x-go-name: FollowingCount + header: + description: Web location of the account's header image. + example: https://example.org/media/some_user/header/original/header.jpeg + type: string + x-go-name: Header + header_static: + description: |- + Web location of a static version of the account's header. + Only relevant when the account's main header is a video or a gif. + example: https://example.org/media/some_user/header/static/header.png + type: string + x-go-name: HeaderStatic + hide_collections: + description: |- + Account has opted to hide their followers/following collections. + Key/value omitted if false. + type: boolean + x-go-name: HideCollections + id: + description: The account id. + example: 01FBVD42CQ3ZEEVMW180SBX03B + type: string + x-go-name: ID + last_status_at: + description: When the account's most recent status was posted (ISO 8601 Datetime). + example: "2021-07-30T09:20:25+00:00" + type: string + x-go-name: LastStatusAt + locked: + description: Account manually approves follow requests. + type: boolean + x-go-name: Locked + moved: + $ref: '#/definitions/account' + mute_expires_at: + description: |- + 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" + type: string + x-go-name: MuteExpiresAt + note: + description: Bio/description of this account. + type: string + x-go-name: Note + role: + $ref: '#/definitions/accountRole' + source: + $ref: '#/definitions/Source' + statuses_count: + description: Number of statuses posted by this account, according to our instance. + format: int64 + type: integer + x-go-name: StatusesCount + suspended: + description: Account has been suspended by our instance. + type: boolean + x-go-name: Suspended + theme: + description: Filename of user-selected CSS theme to include when rendering this account's profile or statuses. Eg., `blurple-light.css`. + type: string + x-go-name: Theme + url: + description: Web location of the account's profile page. + example: https://example.org/@some_user + type: string + x-go-name: URL + username: + description: The username of the account, not including domain. + example: some_user + type: string + x-go-name: Username + title: MutedAccount extends Account with a field used only by the muted user list. + type: object + x-go-name: MutedAccount + x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model nodeinfo: description: 'See: https://nodeinfo.diaspora.software/schema.html' properties: @@ -3363,6 +3509,51 @@ paths: summary: See all lists of yours that contain requested account. tags: - accounts + /api/v1/accounts/{id}/mute: + post: + description: If account was already muted, succeeds anyway. This can be used to update the details of a mute. + operationId: accountMute + parameters: + - description: The ID of the account to block. + in: path + name: id + required: true + type: string + - default: false + description: Mute notifications as well as posts. + in: formData + name: notifications + type: boolean + - default: 0 + description: How long the mute should last, in seconds. If 0 or not provided, mute lasts indefinitely. + in: formData + name: duration + type: number + produces: + - application/json + 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 + security: + - OAuth2 Bearer: + - write:mutes + summary: Mute account by ID. + tags: + - accounts /api/v1/accounts/{id}/note: post: consumes: @@ -3543,6 +3734,39 @@ paths: summary: Unfollow account with id. tags: - accounts + /api/v1/accounts/{id}/unmute: + post: + description: If account was already unmuted (or has never been muted), succeeds anyway. + operationId: accountUnmute + parameters: + - description: The ID of the account to unmute. + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + 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 + security: + - OAuth2 Bearer: + - write:mutes + summary: Unmute account by ID. + tags: + - accounts /api/v1/accounts/alias: post: consumes: @@ -7073,8 +7297,6 @@ paths: /api/v1/mutes: get: description: |- - 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: @@ -7106,14 +7328,14 @@ paths: - application/json responses: "200": - description: "" + description: List of muted accounts, including when their mutes expire (if applicable). headers: Link: description: Links to the next and previous queries. type: string schema: items: - $ref: '#/definitions/account' + $ref: '#/definitions/mutedAccount' type: array "400": description: bad request 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 . + +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 . + +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 . + +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 . + +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 . + +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 . + +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 . + +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 . + +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 . + +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 . + +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 . + +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 . + +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, "", " ") diff --git a/test/envparsing.sh b/test/envparsing.sh index 95412b00b..66250c5af 100755 --- a/test/envparsing.sh +++ b/test/envparsing.sh @@ -28,7 +28,8 @@ EXPECT=$(cat << "EOF" "account-settings-mem-ratio": 0.1, "account-stats-mem-ratio": 2, "application-mem-ratio": 0.1, - "block-mem-ratio": 3, + "block-ids-mem-ratio": 3, + "block-mem-ratio": 2, "boost-of-ids-mem-ratio": 3, "client-mem-ratio": 0.1, "emoji-category-mem-ratio": 0.1, @@ -64,6 +65,8 @@ EXPECT=$(cat << "EOF" "token-mem-ratio": 0.75, "tombstone-mem-ratio": 0.5, "user-mem-ratio": 0.25, + "user-mute-ids-mem-ratio": 3, + "user-mute-mem-ratio": 2, "visibility-mem-ratio": 2, "webfinger-mem-ratio": 0.1 }, diff --git a/testrig/db.go b/testrig/db.go index faa7a910d..67a7e2439 100644 --- a/testrig/db.go +++ b/testrig/db.go @@ -56,6 +56,7 @@ var testModels = []interface{}{ >smodel.ThreadMute{}, >smodel.ThreadToStatus{}, >smodel.User{}, + >smodel.UserMute{}, >smodel.Emoji{}, >smodel.Instance{}, >smodel.Notification{}, @@ -338,6 +339,12 @@ func StandardDBSetup(db db.DB, accounts map[string]*gtsmodel.Account) { } } + for _, v := range NewTestUserMutes() { + if err := db.Put(ctx, v); err != nil { + log.Panic(nil, err) + } + } + if err := db.CreateInstanceAccount(ctx); err != nil { log.Panic(nil, err) } diff --git a/testrig/testmodels.go b/testrig/testmodels.go index 956c898c0..e1d99fed8 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -3392,6 +3392,11 @@ func NewTestFilterStatuses() map[string]*gtsmodel.FilterStatus { } } +func NewTestUserMutes() map[string]*gtsmodel.UserMute { + // Not currently used. + return map[string]*gtsmodel.UserMute{} +} + // GetSignatureForActivity prepares a mock HTTP request as if it were going to deliver activity to destination signed for privkey and pubKeyID, signs the request and returns the header values. func GetSignatureForActivity(activity pub.Activity, pubKeyID string, privkey *rsa.PrivateKey, destination *url.URL) (signatureHeader string, digestHeader string, dateHeader string) { // convert the activity into json bytes