mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2024-12-26 17:10:32 +00:00
[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:
parent
b371c2db47
commit
5e2d4fdb19
47 changed files with 2346 additions and 53 deletions
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
170
internal/api/client/accounts/mute.go
Normal file
170
internal/api/client/accounts/mute.go
Normal 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
|
||||
}
|
173
internal/api/client/accounts/mute_test.go
Normal file
173
internal/api/client/accounts/mute_test.go
Normal 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, ¬ifications, &duration, nil, http.StatusOK, "")
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.True(relationship.Muting)
|
||||
suite.Equal(notifications, relationship.MutingNotifications)
|
||||
}
|
||||
|
||||
func (suite *MuteTestSuite) TestPostMuteFullJSON() {
|
||||
accountID := suite.testAccounts["remote_account_2"].ID
|
||||
// Use a numeric literal with a fractional part to test the JSON-specific handling for non-integer "duration".
|
||||
requestJson := `{
|
||||
"notifications": true,
|
||||
"duration": 86400.1
|
||||
}`
|
||||
relationship, err := suite.postMute(accountID, nil, nil, &requestJson, http.StatusOK, "")
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.True(relationship.Muting)
|
||||
suite.True(relationship.MutingNotifications)
|
||||
}
|
||||
|
||||
func (suite *MuteTestSuite) TestPostMuteMinimal() {
|
||||
accountID := suite.testAccounts["remote_account_3"].ID
|
||||
relationship, err := suite.postMute(accountID, nil, nil, nil, http.StatusOK, "")
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.True(relationship.Muting)
|
||||
suite.False(relationship.MutingNotifications)
|
||||
}
|
||||
|
||||
func (suite *MuteTestSuite) TestPostMuteSelf() {
|
||||
accountID := suite.testAccounts["local_account_1"].ID
|
||||
_, err := suite.postMute(accountID, nil, nil, nil, http.StatusNotAcceptable, `{"error":"Not Acceptable: getMuteTarget: account 01F8MH1H7YV1Z7D2C8K2730QBF cannot mute or unmute itself"}`)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *MuteTestSuite) TestPostMuteNonexistentAccount() {
|
||||
accountID := "not_even_a_real_ULID"
|
||||
_, err := suite.postMute(accountID, nil, nil, nil, http.StatusNotFound, `{"error":"Not Found: getMuteTarget: target account not_even_a_real_ULID not found in the db"}`)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMuteTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(MuteTestSuite))
|
||||
}
|
98
internal/api/client/accounts/unmute.go
Normal file
98
internal/api/client/accounts/unmute.go
Normal 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)
|
||||
|
||||
}
|
136
internal/api/client/accounts/unmute_test.go
Normal file
136
internal/api/client/accounts/unmute_test.go
Normal 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())
|
||||
}
|
||||
}
|
136
internal/api/client/mutes/mutes_test.go
Normal file
136
internal/api/client/mutes/mutes_test.go
Normal 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))
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
155
internal/api/client/mutes/mutesget_test.go
Normal file
155
internal/api/client/mutes/mutesget_test.go
Normal 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 := >smodel.UserMute{
|
||||
ID: "01HZQ4K4MJTZ3RWVAEEJQDKK7M",
|
||||
ExpiresAt: time.Now().Add(time.Duration(1) * time.Hour),
|
||||
AccountID: suite.testAccounts["local_account_1"].ID,
|
||||
TargetAccountID: suite.testAccounts["local_account_2"].ID,
|
||||
}
|
||||
err := suite.db.PutMute(context.Background(), mute1)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
// Mute a user with an indefinite duration.
|
||||
mute2 := >smodel.UserMute{
|
||||
ID: "01HZQ4K641EMWBEJ9A99WST1GP",
|
||||
AccountID: suite.testAccounts["local_account_1"].ID,
|
||||
TargetAccountID: suite.testAccounts["remote_account_1"].ID,
|
||||
}
|
||||
err = suite.db.PutMute(context.Background(), mute2)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
// Fetch all muted accounts for the logged-in account.
|
||||
mutedAccounts, err := suite.getMutedAccounts(http.StatusOK, "")
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
suite.NotEmpty(mutedAccounts)
|
||||
|
||||
// Check that we got the accounts we just muted, and that their mute expiration times are set correctly.
|
||||
// Note that the account list will be in *reverse* order by mute ID.
|
||||
if suite.Len(mutedAccounts, 2) {
|
||||
// This mute expiration should be a string.
|
||||
mutedAccount1 := mutedAccounts[1]
|
||||
suite.Equal(mute1.TargetAccountID, mutedAccount1.ID)
|
||||
suite.NotEmpty(mutedAccount1.MuteExpiresAt)
|
||||
|
||||
// This mute expiration should be null.
|
||||
mutedAccount2 := mutedAccounts[0]
|
||||
suite.Equal(mute2.TargetAccountID, mutedAccount2.ID)
|
||||
suite.Nil(mutedAccount2.MuteExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *MutesTestSuite) TestIndefinitelyMutedAccountSerializesMuteExpirationAsNull() {
|
||||
// Mute a user with an indefinite duration.
|
||||
mute := >smodel.UserMute{
|
||||
ID: "01HZQ4K641EMWBEJ9A99WST1GP",
|
||||
AccountID: suite.testAccounts["local_account_1"].ID,
|
||||
TargetAccountID: suite.testAccounts["remote_account_1"].ID,
|
||||
}
|
||||
err := suite.db.PutMute(context.Background(), mute)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
// Fetch all muted accounts for the logged-in account.
|
||||
// The expected body contains `"mute_expires_at":null`.
|
||||
_, err = suite.getMutedAccounts(http.StatusOK, `[{"id":"01F8MH5ZK5VRH73AKHQM6Y9VNX","username":"foss_satan","acct":"foss_satan@fossbros-anonymous.io","display_name":"big gerald","locked":false,"discoverable":true,"bot":false,"created_at":"2021-09-26T10:52:36.000Z","note":"i post about like, i dunno, stuff, or whatever!!!!","url":"http://fossbros-anonymous.io/@foss_satan","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":0,"following_count":0,"statuses_count":3,"last_status_at":"2021-09-11T09:40:37.000Z","emojis":[],"fields":[],"mute_expires_at":null}]`)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
34
internal/api/model/usermute.go
Normal file
34
internal/api/model/usermute.go
Normal 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"`
|
||||
}
|
4
internal/cache/cache.go
vendored
4
internal/cache/cache.go
vendored
|
@ -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
53
internal/cache/db.go
vendored
|
@ -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(
|
||||
|
|
5
internal/cache/invalidate.go
vendored
5
internal/cache/invalidate.go
vendored
|
@ -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)
|
||||
}
|
||||
|
|
12
internal/cache/size.go
vendored
12
internal/cache/size.go
vendored
|
@ -715,3 +715,15 @@ func sizeofUser() uintptr {
|
|||
ExternalID: exampleID,
|
||||
}))
|
||||
}
|
||||
|
||||
func sizeofUserMute() uintptr {
|
||||
return uintptr(size.Of(>smodel.UserMute{
|
||||
ID: exampleID,
|
||||
CreatedAt: exampleTime,
|
||||
UpdatedAt: exampleTime,
|
||||
ExpiresAt: exampleTime,
|
||||
AccountID: exampleID,
|
||||
TargetAccountID: exampleID,
|
||||
Notifications: util.Ptr(false),
|
||||
}))
|
||||
}
|
||||
|
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
|
@ -2917,7 +2917,7 @@ func (st *ConfigState) SetCacheBlockIDsMemRatio(v float64) {
|
|||
}
|
||||
|
||||
// CacheBlockIDsMemRatioFlag returns the flag name for the 'Cache.BlockIDsMemRatio' field
|
||||
func CacheBlockIDsMemRatioFlag() string { return "cache-block-mem-ratio" }
|
||||
func CacheBlockIDsMemRatioFlag() string { return "cache-block-ids-mem-ratio" }
|
||||
|
||||
// GetCacheBlockIDsMemRatio safely fetches the value for global configuration 'Cache.BlockIDsMemRatio' field
|
||||
func GetCacheBlockIDsMemRatio() float64 { return global.GetCacheBlockIDsMemRatio() }
|
||||
|
@ -3775,6 +3775,56 @@ func GetCacheUserMemRatio() float64 { return global.GetCacheUserMemRatio() }
|
|||
// SetCacheUserMemRatio safely sets the value for global configuration 'Cache.UserMemRatio' field
|
||||
func SetCacheUserMemRatio(v float64) { global.SetCacheUserMemRatio(v) }
|
||||
|
||||
// GetCacheUserMuteMemRatio safely fetches the Configuration value for state's 'Cache.UserMuteMemRatio' field
|
||||
func (st *ConfigState) GetCacheUserMuteMemRatio() (v float64) {
|
||||
st.mutex.RLock()
|
||||
v = st.config.Cache.UserMuteMemRatio
|
||||
st.mutex.RUnlock()
|
||||
return
|
||||
}
|
||||
|
||||
// SetCacheUserMuteMemRatio safely sets the Configuration value for state's 'Cache.UserMuteMemRatio' field
|
||||
func (st *ConfigState) SetCacheUserMuteMemRatio(v float64) {
|
||||
st.mutex.Lock()
|
||||
defer st.mutex.Unlock()
|
||||
st.config.Cache.UserMuteMemRatio = v
|
||||
st.reloadToViper()
|
||||
}
|
||||
|
||||
// CacheUserMuteMemRatioFlag returns the flag name for the 'Cache.UserMuteMemRatio' field
|
||||
func CacheUserMuteMemRatioFlag() string { return "cache-user-mute-mem-ratio" }
|
||||
|
||||
// GetCacheUserMuteMemRatio safely fetches the value for global configuration 'Cache.UserMuteMemRatio' field
|
||||
func GetCacheUserMuteMemRatio() float64 { return global.GetCacheUserMuteMemRatio() }
|
||||
|
||||
// SetCacheUserMuteMemRatio safely sets the value for global configuration 'Cache.UserMuteMemRatio' field
|
||||
func SetCacheUserMuteMemRatio(v float64) { global.SetCacheUserMuteMemRatio(v) }
|
||||
|
||||
// GetCacheUserMuteIDsMemRatio safely fetches the Configuration value for state's 'Cache.UserMuteIDsMemRatio' field
|
||||
func (st *ConfigState) GetCacheUserMuteIDsMemRatio() (v float64) {
|
||||
st.mutex.RLock()
|
||||
v = st.config.Cache.UserMuteIDsMemRatio
|
||||
st.mutex.RUnlock()
|
||||
return
|
||||
}
|
||||
|
||||
// SetCacheUserMuteIDsMemRatio safely sets the Configuration value for state's 'Cache.UserMuteIDsMemRatio' field
|
||||
func (st *ConfigState) SetCacheUserMuteIDsMemRatio(v float64) {
|
||||
st.mutex.Lock()
|
||||
defer st.mutex.Unlock()
|
||||
st.config.Cache.UserMuteIDsMemRatio = v
|
||||
st.reloadToViper()
|
||||
}
|
||||
|
||||
// CacheUserMuteIDsMemRatioFlag returns the flag name for the 'Cache.UserMuteIDsMemRatio' field
|
||||
func CacheUserMuteIDsMemRatioFlag() string { return "cache-user-mute-ids-mem-ratio" }
|
||||
|
||||
// GetCacheUserMuteIDsMemRatio safely fetches the value for global configuration 'Cache.UserMuteIDsMemRatio' field
|
||||
func GetCacheUserMuteIDsMemRatio() float64 { return global.GetCacheUserMuteIDsMemRatio() }
|
||||
|
||||
// SetCacheUserMuteIDsMemRatio safely sets the value for global configuration 'Cache.UserMuteIDsMemRatio' field
|
||||
func SetCacheUserMuteIDsMemRatio(v float64) { global.SetCacheUserMuteIDsMemRatio(v) }
|
||||
|
||||
// GetCacheWebfingerMemRatio safely fetches the Configuration value for state's 'Cache.WebfingerMemRatio' field
|
||||
func (st *ConfigState) GetCacheWebfingerMemRatio() (v float64) {
|
||||
st.mutex.RLock()
|
||||
|
@ -4024,3 +4074,4 @@ func GetRequestIDHeader() string { return global.GetRequestIDHeader() }
|
|||
|
||||
// SetRequestIDHeader safely sets the value for global configuration 'RequestIDHeader' field
|
||||
func SetRequestIDHeader(v string) { global.SetRequestIDHeader(v) }
|
||||
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
gtsmodel "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
func init() {
|
||||
up := func(ctx context.Context, db *bun.DB) error {
|
||||
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||
if _, err := tx.
|
||||
NewCreateTable().
|
||||
Model(>smodel.UserMute{}).
|
||||
IfNotExists().
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.
|
||||
NewCreateIndex().
|
||||
Table("user_mutes").
|
||||
Index("user_mutes_account_id_idx").
|
||||
Column("account_id").
|
||||
IfNotExists().
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
down := func(ctx context.Context, db *bun.DB) error {
|
||||
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := Migrations.Register(up, down); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
306
internal/db/bundb/relationship_mute.go
Normal file
306
internal/db/bundb/relationship_mute.go
Normal 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
|
||||
})
|
||||
}
|
|
@ -510,6 +510,43 @@ func (suite *RelationshipTestSuite) TestDeleteAccountBlocks() {
|
|||
suite.Nil(block)
|
||||
}
|
||||
|
||||
func (suite *RelationshipTestSuite) TestDeleteAccountMutes() {
|
||||
ctx := context.Background()
|
||||
|
||||
// Add a mute.
|
||||
accountID1 := suite.testAccounts["local_account_1"].ID
|
||||
accountID2 := suite.testAccounts["local_account_2"].ID
|
||||
muteID := "01HZGZ3F3C7S1TTPE8F9VPZDCB"
|
||||
err := suite.db.PutMute(ctx, >smodel.UserMute{
|
||||
ID: muteID,
|
||||
AccountID: accountID1,
|
||||
TargetAccountID: accountID2,
|
||||
})
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
// Make sure the mute is in the DB.
|
||||
mute, err := suite.db.GetMute(ctx, accountID1, accountID2)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
if suite.NotNil(mute) {
|
||||
suite.Equal(muteID, mute.ID)
|
||||
}
|
||||
|
||||
// Delete all mutes owned by that account.
|
||||
err = suite.db.DeleteAccountMutes(ctx, accountID1)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
// Mute should be gone.
|
||||
mute, err = suite.db.GetMute(ctx, accountID1, accountID2)
|
||||
suite.ErrorIs(err, db.ErrNoEntries)
|
||||
suite.Nil(mute)
|
||||
}
|
||||
|
||||
func (suite *RelationshipTestSuite) TestGetRelationship() {
|
||||
requestingAccount := suite.testAccounts["local_account_1"]
|
||||
targetAccount := suite.testAccounts["admin_account"]
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
80
internal/filter/usermute/usermute.go
Normal file
80
internal/filter/usermute/usermute.go
Normal 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)
|
||||
}
|
41
internal/gtsmodel/usermute.go
Normal file
41
internal/gtsmodel/usermute.go
Normal 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)
|
||||
}
|
|
@ -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
|
||||
|
|
198
internal/processing/account/mute.go
Normal file
198
internal/processing/account/mute.go
Normal 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 := >smodel.UserMute{
|
||||
AccountID: requestingAccount.ID,
|
||||
Account: requestingAccount,
|
||||
TargetAccountID: targetAccountID,
|
||||
TargetAccount: targetAccount,
|
||||
Notifications: form.Notifications,
|
||||
}
|
||||
if existingMute != nil {
|
||||
mute.ID = existingMute.ID
|
||||
} else {
|
||||
mute.ID = id.NewULID()
|
||||
}
|
||||
if form.Duration != nil {
|
||||
mute.ExpiresAt = time.Now().Add(time.Second * time.Duration(*form.Duration))
|
||||
}
|
||||
|
||||
if err := p.state.DB.PutMute(ctx, mute); err != nil {
|
||||
err = gtserror.Newf("error creating or updating mute in db: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
return p.RelationshipGet(ctx, requestingAccount, targetAccountID)
|
||||
}
|
||||
|
||||
// MuteRemove handles the removal of a mute from requestingAccount to targetAccountID.
|
||||
func (p *Processor) MuteRemove(
|
||||
ctx context.Context,
|
||||
requestingAccount *gtsmodel.Account,
|
||||
targetAccountID string,
|
||||
) (*apimodel.Relationship, gtserror.WithCode) {
|
||||
_, existingMute, errWithCode := p.getMuteTarget(ctx, requestingAccount, targetAccountID)
|
||||
if errWithCode != nil {
|
||||
return nil, errWithCode
|
||||
}
|
||||
|
||||
if existingMute == nil {
|
||||
// Already not muted, nothing to do.
|
||||
return p.RelationshipGet(ctx, requestingAccount, targetAccountID)
|
||||
}
|
||||
|
||||
// We got a mute, remove it from the db.
|
||||
if err := p.state.DB.DeleteMuteByID(ctx, existingMute.ID); err != nil {
|
||||
err := gtserror.Newf("error removing mute from db: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
return p.RelationshipGet(ctx, requestingAccount, targetAccountID)
|
||||
}
|
||||
|
||||
// MutesGet retrieves the user's list of muted accounts, with an extra field for mute expiration (if applicable).
|
||||
func (p *Processor) MutesGet(
|
||||
ctx context.Context,
|
||||
requestingAccount *gtsmodel.Account,
|
||||
page *paging.Page,
|
||||
) (*apimodel.PageableResponse, gtserror.WithCode) {
|
||||
mutes, err := p.state.DB.GetAccountMutes(ctx,
|
||||
requestingAccount.ID,
|
||||
page,
|
||||
)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
err = gtserror.Newf("couldn't list account's mutes: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
// Check for empty response.
|
||||
count := len(mutes)
|
||||
if len(mutes) == 0 {
|
||||
return util.EmptyPageableResponse(), nil
|
||||
}
|
||||
|
||||
// Get the lowest and highest
|
||||
// ID values, used for paging.
|
||||
lo := mutes[count-1].ID
|
||||
hi := mutes[0].ID
|
||||
|
||||
items := make([]interface{}, 0, count)
|
||||
|
||||
now := time.Now()
|
||||
for _, mute := range mutes {
|
||||
// Skip accounts for which the mute has expired.
|
||||
if mute.Expired(now) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Convert target account to frontend API model. (target will never be nil)
|
||||
account, err := p.converter.AccountToAPIAccountPublic(ctx, mute.TargetAccount)
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "error converting account to public api account: %v", err)
|
||||
continue
|
||||
}
|
||||
mutedAccount := &apimodel.MutedAccount{
|
||||
Account: *account,
|
||||
}
|
||||
// Add the mute expiration field (unique to this API).
|
||||
if !mute.ExpiresAt.IsZero() {
|
||||
mutedAccount.MuteExpiresAt = util.Ptr(util.FormatISO8601(mute.ExpiresAt))
|
||||
}
|
||||
|
||||
// Append target to return items.
|
||||
items = append(items, mutedAccount)
|
||||
}
|
||||
|
||||
return paging.PackageResponse(paging.ResponseParams{
|
||||
Items: items,
|
||||
Path: "/api/v1/mutes",
|
||||
Next: page.Next(lo, hi),
|
||||
Prev: page.Prev(lo, hi),
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (p *Processor) getMuteTarget(
|
||||
ctx context.Context,
|
||||
requestingAccount *gtsmodel.Account,
|
||||
targetAccountID string,
|
||||
) (*gtsmodel.Account, *gtsmodel.UserMute, gtserror.WithCode) {
|
||||
// Account should not mute or unmute itself.
|
||||
if requestingAccount.ID == targetAccountID {
|
||||
err := gtserror.Newf("account %s cannot mute or unmute itself", requestingAccount.ID)
|
||||
return nil, nil, gtserror.NewErrorNotAcceptable(err, err.Error())
|
||||
}
|
||||
|
||||
// Ensure target account retrievable.
|
||||
targetAccount, err := p.state.DB.GetAccountByID(ctx, targetAccountID)
|
||||
if err != nil {
|
||||
if !errors.Is(err, db.ErrNoEntries) {
|
||||
// Real db error.
|
||||
err = gtserror.Newf("db error looking for target account %s: %w", targetAccountID, err)
|
||||
return nil, nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
// Account not found.
|
||||
err = gtserror.Newf("target account %s not found in the db", targetAccountID)
|
||||
return nil, nil, gtserror.NewErrorNotFound(err, err.Error())
|
||||
}
|
||||
|
||||
// Check if currently muted.
|
||||
mute, err := p.state.DB.GetMute(ctx, requestingAccount.ID, targetAccountID)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
err = gtserror.Newf("db error checking existing mute: %w", err)
|
||||
return nil, nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
return targetAccount, mute, nil
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -24,6 +24,9 @@ import (
|
|||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/status"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
|
@ -49,6 +52,13 @@ func (p *Processor) NotificationsGet(ctx context.Context, authed *oauth.Auth, ma
|
|||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
mutes, err := p.state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), authed.Account.ID, nil)
|
||||
if err != nil {
|
||||
err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", authed.Account.ID, err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
compiledMutes := usermute.NewCompiledUserMuteList(mutes)
|
||||
|
||||
var (
|
||||
items = make([]interface{}, 0, count)
|
||||
nextMaxIDValue string
|
||||
|
@ -76,9 +86,11 @@ func (p *Processor) NotificationsGet(ctx context.Context, authed *oauth.Auth, ma
|
|||
continue
|
||||
}
|
||||
|
||||
item, err := p.converter.NotificationToAPINotification(ctx, n, filters)
|
||||
item, err := p.converter.NotificationToAPINotification(ctx, n, filters, compiledMutes)
|
||||
if err != nil {
|
||||
log.Debugf(ctx, "skipping notification %s because it couldn't be converted to its api representation: %s", n.ID, err)
|
||||
if !errors.Is(err, status.ErrHideStatus) {
|
||||
log.Debugf(ctx, "skipping notification %s because it couldn't be converted to its api representation: %s", n.ID, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
|
@ -116,7 +128,14 @@ func (p *Processor) NotificationGet(ctx context.Context, account *gtsmodel.Accou
|
|||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
apiNotif, err := p.converter.NotificationToAPINotification(ctx, notif, filters)
|
||||
mutes, err := p.state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), account.ID, nil)
|
||||
if err != nil {
|
||||
err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", account.ID, err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
compiledMutes := usermute.NewCompiledUserMuteList(mutes)
|
||||
|
||||
apiNotif, err := p.converter.NotificationToAPINotification(ctx, notif, filters, compiledMutes)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoEntries) {
|
||||
return nil, gtserror.NewErrorNotFound(err)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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, "", " ")
|
||||
|
|
|
@ -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
|
||||
},
|
||||
|
|
|
@ -56,6 +56,7 @@ var testModels = []interface{}{
|
|||
>smodel.ThreadMute{},
|
||||
>smodel.ThreadToStatus{},
|
||||
>smodel.User{},
|
||||
>smodel.UserMute{},
|
||||
>smodel.Emoji{},
|
||||
>smodel.Instance{},
|
||||
>smodel.Notification{},
|
||||
|
@ -338,6 +339,12 @@ func StandardDBSetup(db db.DB, accounts map[string]*gtsmodel.Account) {
|
|||
}
|
||||
}
|
||||
|
||||
for _, v := range NewTestUserMutes() {
|
||||
if err := db.Put(ctx, v); err != nil {
|
||||
log.Panic(nil, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.CreateInstanceAccount(ctx); err != nil {
|
||||
log.Panic(nil, err)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue