[feature] User muting (#2960)

* User muting

* Address review feedback

* Rename uniqueness constraint on user_mutes to match convention

* Remove unused account_id from where clause

* Add UserMute to NewTestDB

* Update test/envparsing.sh with new and fixed cache stuff

* Address tobi's review comments

* Make compiledUserMuteListEntry.expired consistent with UserMute.Expired

* Make sure mute_expires_at is serialized as an explicit null for indefinite mutes

---------

Co-authored-by: tobi <tobi.smethurst@protonmail.com>
This commit is contained in:
Vyr Cossont 2024-06-06 09:38:02 -07:00 committed by GitHub
parent b371c2db47
commit 5e2d4fdb19
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 2346 additions and 53 deletions

View file

@ -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

View file

@ -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)

View file

@ -0,0 +1,170 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package accounts
import (
"errors"
"fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
// AccountMutePOSTHandler swagger:operation POST /api/v1/accounts/{id}/mute accountMute
//
// Mute account by ID.
//
// If account was already muted, succeeds anyway. This can be used to update the details of a mute.
//
// ---
// tags:
// - accounts
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// type: string
// description: The ID of the account to block.
// in: path
// required: true
// -
// name: notifications
// type: boolean
// description: Mute notifications as well as posts.
// in: formData
// required: false
// default: false
// -
// name: duration
// type: number
// description: How long the mute should last, in seconds. If 0 or not provided, mute lasts indefinitely.
// in: formData
// required: false
// default: 0
//
// security:
// - OAuth2 Bearer:
// - write:mutes
//
// responses:
// '200':
// description: Your relationship to the account.
// schema:
// "$ref": "#/definitions/accountRelationship"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '403':
// description: forbidden to moved accounts
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) AccountMutePOSTHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
targetAcctID := c.Param(IDKey)
if targetAcctID == "" {
err := errors.New("no account id specified")
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
form := &apimodel.UserMuteCreateUpdateRequest{}
if err := c.ShouldBind(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
if err := normalizeCreateUpdateMute(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, err.Error()), m.processor.InstanceGetV1)
return
}
relationship, errWithCode := m.processor.Account().MuteCreate(
c.Request.Context(),
authed.Account,
targetAcctID,
form,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, relationship)
}
func normalizeCreateUpdateMute(form *apimodel.UserMuteCreateUpdateRequest) error {
// Apply defaults for missing fields.
form.Notifications = util.Ptr(util.PtrValueOr(form.Notifications, false))
// Normalize mute duration if necessary.
// If we parsed this as JSON, expires_in
// may be either a float64 or a string.
if ei := form.DurationI; ei != nil {
switch e := ei.(type) {
case float64:
form.Duration = util.Ptr(int(e))
case string:
duration, err := strconv.Atoi(e)
if err != nil {
return fmt.Errorf("could not parse duration value %s as integer: %w", e, err)
}
form.Duration = &duration
default:
return fmt.Errorf("could not parse expires_in type %T as integer", ei)
}
}
// Interpret zero as indefinite duration.
if form.Duration != nil && *form.Duration == 0 {
form.Duration = nil
}
return nil
}

View file

@ -0,0 +1,173 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package accounts_test
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/accounts"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/testrig"
)
type MuteTestSuite struct {
AccountStandardTestSuite
}
func (suite *MuteTestSuite) postMute(
accountID string,
notifications *bool,
duration *int,
requestJson *string,
expectedHTTPStatus int,
expectedBody string,
) (*apimodel.Relationship, error) {
// instantiate recorder + test context
recorder := httptest.NewRecorder()
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"]))
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
// create the request
ctx.Request = httptest.NewRequest(http.MethodPut, config.GetProtocol()+"://"+config.GetHost()+"/api/"+accounts.BasePath+"/"+accountID+"/mute", nil)
ctx.Request.Header.Set("accept", "application/json")
if requestJson != nil {
ctx.Request.Header.Set("content-type", "application/json")
ctx.Request.Body = io.NopCloser(strings.NewReader(*requestJson))
} else {
ctx.Request.Form = make(url.Values)
if notifications != nil {
ctx.Request.Form["notifications"] = []string{strconv.FormatBool(*notifications)}
}
if duration != nil {
ctx.Request.Form["duration"] = []string{strconv.Itoa(*duration)}
}
}
ctx.AddParam("id", accountID)
// trigger the handler
suite.accountsModule.AccountMutePOSTHandler(ctx)
// read the response
result := recorder.Result()
defer result.Body.Close()
b, err := io.ReadAll(result.Body)
if err != nil {
return nil, err
}
errs := gtserror.NewMultiError(2)
// check code + body
if resultCode := recorder.Code; expectedHTTPStatus != resultCode {
errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode)
if expectedBody == "" {
return nil, errs.Combine()
}
}
// if we got an expected body, return early
if expectedBody != "" {
if string(b) != expectedBody {
errs.Appendf("expected %s got %s", expectedBody, string(b))
}
return nil, errs.Combine()
}
resp := &apimodel.Relationship{}
if err := json.Unmarshal(b, resp); err != nil {
return nil, err
}
return resp, nil
}
func (suite *MuteTestSuite) TestPostMuteFull() {
accountID := suite.testAccounts["remote_account_1"].ID
notifications := true
duration := 86400
relationship, err := suite.postMute(accountID, &notifications, &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))
}

View file

@ -0,0 +1,98 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package accounts
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
// AccountUnmutePOSTHandler swagger:operation POST /api/v1/accounts/{id}/unmute accountUnmute
//
// Unmute account by ID.
//
// If account was already unmuted (or has never been muted), succeeds anyway.
//
// ---
// tags:
// - accounts
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// type: string
// description: The ID of the account to unmute.
// in: path
// required: true
//
// security:
// - OAuth2 Bearer:
// - write:mutes
//
// responses:
// '200':
// name: account relationship
// description: Your relationship to this account.
// schema:
// "$ref": "#/definitions/accountRelationship"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) AccountUnmutePOSTHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
targetAcctID := c.Param(IDKey)
if targetAcctID == "" {
err := errors.New("no account id specified")
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
relationship, errWithCode := m.processor.Account().MuteRemove(c.Request.Context(), authed.Account, targetAcctID)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, relationship)
}

View file

@ -0,0 +1,136 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package accounts_test
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"github.com/superseriousbusiness/gotosocial/internal/api/client/accounts"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/testrig"
)
func (suite *MuteTestSuite) postUnmute(
accountID string,
expectedHTTPStatus int,
expectedBody string,
) (*apimodel.Relationship, error) {
// instantiate recorder + test context
recorder := httptest.NewRecorder()
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"]))
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
// create the request
ctx.Request = httptest.NewRequest(http.MethodPut, config.GetProtocol()+"://"+config.GetHost()+"/api/"+accounts.BasePath+"/"+accountID+"/unmute", nil)
ctx.Request.Header.Set("accept", "application/json")
ctx.AddParam("id", accountID)
// trigger the handler
suite.accountsModule.AccountUnmutePOSTHandler(ctx)
// read the response
result := recorder.Result()
defer result.Body.Close()
b, err := io.ReadAll(result.Body)
if err != nil {
return nil, err
}
errs := gtserror.NewMultiError(2)
// check code + body
if resultCode := recorder.Code; expectedHTTPStatus != resultCode {
errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode)
if expectedBody == "" {
return nil, errs.Combine()
}
}
// if we got an expected body, return early
if expectedBody != "" {
if string(b) != expectedBody {
errs.Appendf("expected %s got %s", expectedBody, string(b))
}
return nil, errs.Combine()
}
resp := &apimodel.Relationship{}
if err := json.Unmarshal(b, resp); err != nil {
return nil, err
}
return resp, nil
}
func (suite *MuteTestSuite) TestPostUnmuteWithoutPreviousMute() {
accountID := suite.testAccounts["remote_account_4"].ID
relationship, err := suite.postUnmute(accountID, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
suite.False(relationship.Muting)
suite.False(relationship.MutingNotifications)
}
func (suite *MuteTestSuite) TestPostWithPreviousMute() {
accountID := suite.testAccounts["local_account_2"].ID
relationship, err := suite.postMute(accountID, nil, nil, nil, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
suite.True(relationship.Muting)
suite.False(relationship.MutingNotifications)
relationship, err = suite.postUnmute(accountID, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
suite.False(relationship.Muting)
suite.False(relationship.MutingNotifications)
}
func (suite *MuteTestSuite) TestPostUnmuteSelf() {
accountID := suite.testAccounts["local_account_1"].ID
_, err := suite.postUnmute(accountID, http.StatusNotAcceptable, `{"error":"Not Acceptable: getMuteTarget: account 01F8MH1H7YV1Z7D2C8K2730QBF cannot mute or unmute itself"}`)
if err != nil {
suite.FailNow(err.Error())
}
}
func (suite *MuteTestSuite) TestPostUnmuteNonexistentAccount() {
accountID := "not_even_a_real_ULID"
_, err := suite.postUnmute(accountID, http.StatusNotFound, `{"error":"Not Found: getMuteTarget: target account not_even_a_real_ULID not found in the db"}`)
if err != nil {
suite.FailNow(err.Error())
}
}

View file

@ -0,0 +1,136 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package mutes_test
import (
"bytes"
"fmt"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/mutes"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/email"
"github.com/superseriousbusiness/gotosocial/internal/federation"
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/processing"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/storage"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/testrig"
)
type MutesTestSuite struct {
// standard suite interfaces
suite.Suite
db db.DB
storage *storage.Driver
mediaManager *media.Manager
federator *federation.Federator
processor *processing.Processor
emailSender email.Sender
sentEmails map[string]string
state state.State
// standard suite models
testTokens map[string]*gtsmodel.Token
testClients map[string]*gtsmodel.Client
testApplications map[string]*gtsmodel.Application
testUsers map[string]*gtsmodel.User
testAccounts map[string]*gtsmodel.Account
// module being tested
mutesModule *mutes.Module
}
func (suite *MutesTestSuite) SetupSuite() {
suite.testTokens = testrig.NewTestTokens()
suite.testClients = testrig.NewTestClients()
suite.testApplications = testrig.NewTestApplications()
suite.testUsers = testrig.NewTestUsers()
suite.testAccounts = testrig.NewTestAccounts()
}
func (suite *MutesTestSuite) SetupTest() {
suite.state.Caches.Init()
testrig.StartNoopWorkers(&suite.state)
testrig.InitTestConfig()
testrig.InitTestLog()
suite.db = testrig.NewTestDB(&suite.state)
suite.state.DB = suite.db
suite.storage = testrig.NewInMemoryStorage()
suite.state.Storage = suite.storage
testrig.StartTimelines(
&suite.state,
visibility.NewFilter(&suite.state),
typeutils.NewConverter(&suite.state),
)
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
suite.sentEmails = make(map[string]string)
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails)
suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
suite.mutesModule = mutes.New(suite.processor)
testrig.StandardDBSetup(suite.db, nil)
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
}
func (suite *MutesTestSuite) TearDownTest() {
testrig.StandardDBTeardown(suite.db)
testrig.StandardStorageTeardown(suite.storage)
testrig.StopWorkers(&suite.state)
}
func (suite *MutesTestSuite) newContext(recorder *httptest.ResponseRecorder, requestMethod string, requestBody []byte, requestPath string, bodyContentType string) *gin.Context {
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"]))
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
protocol := config.GetProtocol()
host := config.GetHost()
baseURI := fmt.Sprintf("%s://%s", protocol, host)
requestURI := fmt.Sprintf("%s/%s", baseURI, requestPath)
ctx.Request = httptest.NewRequest(requestMethod, requestURI, bytes.NewReader(requestBody)) // the endpoint we're hitting
if bodyContentType != "" {
ctx.Request.Header.Set("Content-Type", bodyContentType)
}
ctx.Request.Header.Set("accept", "application/json")
return ctx
}
func TestMutesTestSuite(t *testing.T) {
suite.Run(t, new(MutesTestSuite))
}

View file

@ -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)
}

View file

@ -0,0 +1,155 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package mutes_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"time"
"github.com/superseriousbusiness/gotosocial/internal/api/client/mutes"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/testrig"
)
func (suite *MutesTestSuite) getMutedAccounts(
expectedHTTPStatus int,
expectedBody string,
) ([]*apimodel.MutedAccount, error) {
// instantiate recorder + test context
recorder := httptest.NewRecorder()
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"]))
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
// create the request
ctx.Request = httptest.NewRequest(http.MethodGet, config.GetProtocol()+"://"+config.GetHost()+"/api/"+mutes.BasePath, nil)
ctx.Request.Header.Set("accept", "application/json")
// trigger the handler
suite.mutesModule.MutesGETHandler(ctx)
// read the response
result := recorder.Result()
defer result.Body.Close()
b, err := io.ReadAll(result.Body)
if err != nil {
return nil, err
}
errs := gtserror.NewMultiError(2)
// check code + body
if resultCode := recorder.Code; expectedHTTPStatus != resultCode {
errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode)
if expectedBody == "" {
return nil, errs.Combine()
}
}
// if we got an expected body, return early
if expectedBody != "" {
if string(b) != expectedBody {
errs.Appendf("expected %s got %s", expectedBody, string(b))
}
return nil, errs.Combine()
}
resp := make([]*apimodel.MutedAccount, 0)
if err := json.Unmarshal(b, &resp); err != nil {
return nil, err
}
return resp, nil
}
func (suite *MutesTestSuite) TestGetMutedAccounts() {
// Mute a user with a finite duration.
mute1 := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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())
}
}

View file

@ -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

View file

@ -0,0 +1,34 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package model
// UserMuteCreateUpdateRequest captures params for creating or updating a user mute.
//
// swagger:ignore
type UserMuteCreateUpdateRequest struct {
// Should the mute apply to notifications from that user?
//
// Example: true
Notifications *bool `form:"notifications" json:"notifications" xml:"notifications"`
// Number of seconds from now that the mute should expire. If omitted or 0, mute never expires.
Duration *int `json:"-" form:"duration" xml:"duration"`
// Number of seconds from now that the mute should expire. If omitted or 0, mute never expires.
//
// Example: 86400
DurationI interface{} `json:"duration"`
}

View file

@ -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)
}

53
internal/cache/db.go vendored
View file

@ -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(

View file

@ -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)
}

View file

@ -715,3 +715,15 @@ func sizeofUser() uintptr {
ExternalID: exampleID,
}))
}
func sizeofUserMute() uintptr {
return uintptr(size.Of(&gtsmodel.UserMute{
ID: exampleID,
CreatedAt: exampleTime,
UpdatedAt: exampleTime,
ExpiresAt: exampleTime,
AccountID: exampleID,
TargetAccountID: exampleID,
Notifications: util.Ptr(false),
}))
}

View file

@ -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"`
}

View file

@ -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,
},

View file

@ -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) }

View file

@ -0,0 +1,61 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package migrations
import (
"context"
gtsmodel "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/uptrace/bun"
)
func init() {
up := func(ctx context.Context, db *bun.DB) error {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
if _, err := tx.
NewCreateTable().
Model(&gtsmodel.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)
}
}

View file

@ -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
}

View file

@ -0,0 +1,306 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package bundb
import (
"context"
"errors"
"slices"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
)
func (r *relationshipDB) IsMuted(ctx context.Context, sourceAccountID string, targetAccountID string) (bool, error) {
mute, err := r.GetMute(
gtscontext.SetBarebones(ctx),
sourceAccountID,
targetAccountID,
)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
return false, err
}
return mute != nil, nil
}
func (r *relationshipDB) GetMuteByID(ctx context.Context, id string) (*gtsmodel.UserMute, error) {
return r.getMute(
ctx,
"ID",
func(mute *gtsmodel.UserMute) error {
return r.db.NewSelect().Model(mute).
Where("? = ?", bun.Ident("id"), id).
Scan(ctx)
},
id,
)
}
func (r *relationshipDB) GetMute(
ctx context.Context,
sourceAccountID string,
targetAccountID string,
) (*gtsmodel.UserMute, error) {
return r.getMute(
ctx,
"AccountID,TargetAccountID",
func(mute *gtsmodel.UserMute) error {
return r.db.NewSelect().Model(mute).
Where("? = ?", bun.Ident("account_id"), sourceAccountID).
Where("? = ?", bun.Ident("target_account_id"), targetAccountID).
Scan(ctx)
},
sourceAccountID,
targetAccountID,
)
}
func (r *relationshipDB) getMutesByIDs(ctx context.Context, ids []string) ([]*gtsmodel.UserMute, error) {
// Load all mutes IDs via cache loader callbacks.
mutes, err := r.state.Caches.GTS.UserMute.LoadIDs("ID",
ids,
func(uncached []string) ([]*gtsmodel.UserMute, error) {
// Preallocate expected length of uncached mutes.
mutes := make([]*gtsmodel.UserMute, 0, len(uncached))
// Perform database query scanning
// the remaining (uncached) IDs.
if err := r.db.NewSelect().
Model(&mutes).
Where("? IN (?)", bun.Ident("id"), bun.In(uncached)).
Scan(ctx); err != nil {
return nil, err
}
return mutes, nil
},
)
if err != nil {
return nil, err
}
// Reorder the mutes by their
// IDs to ensure in correct order.
getID := func(b *gtsmodel.UserMute) string { return b.ID }
util.OrderBy(mutes, ids, getID)
if gtscontext.Barebones(ctx) {
// no need to fully populate.
return mutes, nil
}
// Populate all loaded mutes, removing those we fail to
// populate (removes needing so many nil checks everywhere).
mutes = slices.DeleteFunc(mutes, func(mute *gtsmodel.UserMute) bool {
if err := r.populateMute(ctx, mute); err != nil {
log.Errorf(ctx, "error populating mute %s: %v", mute.ID, err)
return true
}
return false
})
return mutes, nil
}
func (r *relationshipDB) getMute(
ctx context.Context,
lookup string,
dbQuery func(*gtsmodel.UserMute) error,
keyParts ...any,
) (*gtsmodel.UserMute, error) {
// Fetch mute from cache with loader callback
mute, err := r.state.Caches.GTS.UserMute.LoadOne(lookup, func() (*gtsmodel.UserMute, error) {
var mute gtsmodel.UserMute
// Not cached! Perform database query
if err := dbQuery(&mute); err != nil {
return nil, err
}
return &mute, nil
}, keyParts...)
if err != nil {
// already processe
return nil, err
}
if gtscontext.Barebones(ctx) {
// Only a barebones model was requested.
return mute, nil
}
if err := r.populateMute(ctx, mute); err != nil {
return nil, err
}
return mute, nil
}
func (r *relationshipDB) populateMute(ctx context.Context, mute *gtsmodel.UserMute) error {
var (
errs gtserror.MultiError
err error
)
if mute.Account == nil {
// Mute origin account is not set, fetch from database.
mute.Account, err = r.state.DB.GetAccountByID(
gtscontext.SetBarebones(ctx),
mute.AccountID,
)
if err != nil {
errs.Appendf("error populating mute account: %w", err)
}
}
if mute.TargetAccount == nil {
// Mute target account is not set, fetch from database.
mute.TargetAccount, err = r.state.DB.GetAccountByID(
gtscontext.SetBarebones(ctx),
mute.TargetAccountID,
)
if err != nil {
errs.Appendf("error populating mute target account: %w", err)
}
}
return errs.Combine()
}
func (r *relationshipDB) PutMute(ctx context.Context, mute *gtsmodel.UserMute) error {
return r.state.Caches.GTS.UserMute.Store(mute, func() error {
_, err := NewUpsert(r.db).Model(mute).Constraint("id").Exec(ctx)
return err
})
}
func (r *relationshipDB) DeleteMuteByID(ctx context.Context, id string) error {
// Load mute into cache before attempting a delete,
// as we need it cached in order to trigger the invalidate
// callback. This in turn invalidates others.
_, err := r.GetMuteByID(gtscontext.SetBarebones(ctx), id)
if err != nil {
if errors.Is(err, db.ErrNoEntries) {
// not an issue.
err = nil
}
return err
}
// Drop this now-cached mute on return after delete.
defer r.state.Caches.GTS.UserMute.Invalidate("ID", id)
// Finally delete mute from DB.
_, err = r.db.NewDelete().
Table("user_mutes").
Where("? = ?", bun.Ident("id"), id).
Exec(ctx)
return err
}
func (r *relationshipDB) DeleteAccountMutes(ctx context.Context, accountID string) error {
var muteIDs []string
// Get full list of IDs.
if err := r.db.NewSelect().
Column("id").
Table("user_mutes").
WhereOr("? = ? OR ? = ?",
bun.Ident("account_id"),
accountID,
bun.Ident("target_account_id"),
accountID,
).
Scan(ctx, &muteIDs); err != nil {
return err
}
defer func() {
// Invalidate all account's incoming / outoing mutes on return.
r.state.Caches.GTS.UserMute.Invalidate("AccountID", accountID)
r.state.Caches.GTS.UserMute.Invalidate("TargetAccountID", accountID)
}()
// Load all mutes into cache, this *really* isn't great
// but it is the only way we can ensure we invalidate all
// related caches correctly (e.g. visibility).
_, err := r.GetAccountMutes(ctx, accountID, nil)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
return err
}
// Finally delete all from DB.
_, err = r.db.NewDelete().
Table("user_mutes").
Where("? IN (?)", bun.Ident("id"), bun.In(muteIDs)).
Exec(ctx)
return err
}
func (r *relationshipDB) GetAccountMutes(
ctx context.Context,
accountID string,
page *paging.Page,
) ([]*gtsmodel.UserMute, error) {
muteIDs, err := r.getAccountMuteIDs(ctx, accountID, page)
if err != nil {
return nil, err
}
return r.getMutesByIDs(ctx, muteIDs)
}
func (r *relationshipDB) getAccountMuteIDs(ctx context.Context, accountID string, page *paging.Page) ([]string, error) {
return loadPagedIDs(&r.state.Caches.GTS.UserMuteIDs, accountID, page, func() ([]string, error) {
var muteIDs []string
// Mute IDs not in cache. Perform DB query.
if _, err := r.db.
NewSelect().
TableExpr("?", bun.Ident("user_mutes")).
ColumnExpr("?", bun.Ident("id")).
Where("? = ?", bun.Ident("account_id"), accountID).
WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery {
var notYetExpiredSQL string
switch r.db.Dialect().Name() {
case dialect.SQLite:
notYetExpiredSQL = "? > DATE('now')"
case dialect.PG:
notYetExpiredSQL = "? > NOW()"
default:
log.Panicf(nil, "db conn %s was neither pg nor sqlite", r.db)
}
return q.
Where("? IS NULL", bun.Ident("expires_at")).
WhereOr(notYetExpiredSQL, bun.Ident("expires_at"))
}).
OrderExpr("? DESC", bun.Ident("id")).
Exec(ctx, &muteIDs); // nocollapse
err != nil && !errors.Is(err, db.ErrNoEntries) {
return nil, err
}
return muteIDs, nil
})
}

View file

@ -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, &gtsmodel.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"]

View file

@ -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)
}

View file

@ -0,0 +1,80 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package usermute
import (
"time"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
type compiledUserMuteListEntry struct {
ExpiresAt time.Time
Notifications bool
}
func (e *compiledUserMuteListEntry) appliesInContext(filterContext statusfilter.FilterContext) bool {
switch filterContext {
case statusfilter.FilterContextHome:
return true
case statusfilter.FilterContextNotifications:
return e.Notifications
case statusfilter.FilterContextPublic:
return true
case statusfilter.FilterContextThread:
return true
case statusfilter.FilterContextAccount:
return false
}
return false
}
func (e *compiledUserMuteListEntry) expired(now time.Time) bool {
return !e.ExpiresAt.IsZero() && !e.ExpiresAt.After(now)
}
type CompiledUserMuteList struct {
byTargetAccountID map[string]compiledUserMuteListEntry
}
func NewCompiledUserMuteList(mutes []*gtsmodel.UserMute) (c *CompiledUserMuteList) {
c = &CompiledUserMuteList{byTargetAccountID: make(map[string]compiledUserMuteListEntry, len(mutes))}
for _, mute := range mutes {
c.byTargetAccountID[mute.TargetAccountID] = compiledUserMuteListEntry{
ExpiresAt: mute.ExpiresAt,
Notifications: *mute.Notifications,
}
}
return
}
func (c *CompiledUserMuteList) Len() int {
if c == nil {
return 0
}
return len(c.byTargetAccountID)
}
func (c *CompiledUserMuteList) Matches(accountID string, filterContext statusfilter.FilterContext, now time.Time) bool {
if c == nil {
return false
}
e, found := c.byTargetAccountID[accountID]
return found && e.appliesInContext(filterContext) && !e.expired(now)
}

View file

@ -0,0 +1,41 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package gtsmodel
import (
"time"
)
// UserMute refers to the muting of one account by another.
type UserMute struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
ExpiresAt time.Time `bun:"type:timestamptz,nullzero"` // Time mute should expire. If null, should not expire.
AccountID string `bun:"type:CHAR(26),unique:user_mutes_account_id_target_account_id_uniq,notnull,nullzero"` // Who does this mute originate from?
Account *Account `bun:"-"` // Account corresponding to accountID
TargetAccountID string `bun:"type:CHAR(26),unique:user_mutes_account_id_target_account_id_uniq,notnull,nullzero"` // Who is the target of this mute?
TargetAccount *Account `bun:"-"` // Account corresponding to targetAccountID
Notifications *bool `bun:",nullzero,notnull,default:false"` // Apply mute to notifications as well as statuses.
}
// Expired returns whether the mute has expired at a given time.
// Mutes without an expiration timestamp never expire.
func (u *UserMute) Expired(now time.Time) bool {
return !u.ExpiresAt.IsZero() && !u.ExpiresAt.After(now)
}

View file

@ -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

View file

@ -0,0 +1,198 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package account
import (
"context"
"errors"
"time"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
// MuteCreate handles the creation or updating of a mute from requestingAccount to targetAccountID.
// The form params should have already been normalized by the time they reach this function.
func (p *Processor) MuteCreate(
ctx context.Context,
requestingAccount *gtsmodel.Account,
targetAccountID string,
form *apimodel.UserMuteCreateUpdateRequest,
) (*apimodel.Relationship, gtserror.WithCode) {
targetAccount, existingMute, errWithCode := p.getMuteTarget(ctx, requestingAccount, targetAccountID)
if errWithCode != nil {
return nil, errWithCode
}
if existingMute != nil &&
*existingMute.Notifications == *form.Notifications &&
existingMute.ExpiresAt.IsZero() && form.Duration == nil {
// Mute already exists and doesn't require updating, nothing to do.
return p.RelationshipGet(ctx, requestingAccount, targetAccountID)
}
// Create a new mute or update an existing one.
mute := &gtsmodel.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
}

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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)
}

View file

@ -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)

View file

@ -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

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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 {
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)

View file

@ -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
}

View file

@ -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
}

View file

@ -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())
}

View file

@ -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)

View file

@ -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

View file

@ -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),
}
}

View file

@ -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)
}

View file

@ -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, "", " ")

View file

@ -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
},

View file

@ -56,6 +56,7 @@ var testModels = []interface{}{
&gtsmodel.ThreadMute{},
&gtsmodel.ThreadToStatus{},
&gtsmodel.User{},
&gtsmodel.UserMute{},
&gtsmodel.Emoji{},
&gtsmodel.Instance{},
&gtsmodel.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)
}

View file

@ -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