[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
|
@ -288,11 +288,6 @@ definitions:
|
||||||
x-go-name: Locked
|
x-go-name: Locked
|
||||||
moved:
|
moved:
|
||||||
$ref: '#/definitions/account'
|
$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:
|
note:
|
||||||
description: Bio/description of this account.
|
description: Bio/description of this account.
|
||||||
type: string
|
type: string
|
||||||
|
@ -1931,6 +1926,157 @@ definitions:
|
||||||
type: object
|
type: object
|
||||||
x-go-name: MediaMeta
|
x-go-name: MediaMeta
|
||||||
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
|
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:
|
nodeinfo:
|
||||||
description: 'See: https://nodeinfo.diaspora.software/schema.html'
|
description: 'See: https://nodeinfo.diaspora.software/schema.html'
|
||||||
properties:
|
properties:
|
||||||
|
@ -3363,6 +3509,51 @@ paths:
|
||||||
summary: See all lists of yours that contain requested account.
|
summary: See all lists of yours that contain requested account.
|
||||||
tags:
|
tags:
|
||||||
- accounts
|
- 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:
|
/api/v1/accounts/{id}/note:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
|
@ -3543,6 +3734,39 @@ paths:
|
||||||
summary: Unfollow account with id.
|
summary: Unfollow account with id.
|
||||||
tags:
|
tags:
|
||||||
- accounts
|
- 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:
|
/api/v1/accounts/alias:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
|
@ -7073,8 +7297,6 @@ paths:
|
||||||
/api/v1/mutes:
|
/api/v1/mutes:
|
||||||
get:
|
get:
|
||||||
description: |-
|
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.
|
The next and previous queries can be parsed from the returned Link header.
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
|
@ -7106,14 +7328,14 @@ paths:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: ""
|
description: List of muted accounts, including when their mutes expire (if applicable).
|
||||||
headers:
|
headers:
|
||||||
Link:
|
Link:
|
||||||
description: Links to the next and previous queries.
|
description: Links to the next and previous queries.
|
||||||
type: string
|
type: string
|
||||||
schema:
|
schema:
|
||||||
items:
|
items:
|
||||||
$ref: '#/definitions/account'
|
$ref: '#/definitions/mutedAccount'
|
||||||
type: array
|
type: array
|
||||||
"400":
|
"400":
|
||||||
description: bad request
|
description: bad request
|
||||||
|
|
|
@ -45,12 +45,14 @@ const (
|
||||||
FollowPath = BasePathWithID + "/follow"
|
FollowPath = BasePathWithID + "/follow"
|
||||||
ListsPath = BasePathWithID + "/lists"
|
ListsPath = BasePathWithID + "/lists"
|
||||||
LookupPath = BasePath + "/lookup"
|
LookupPath = BasePath + "/lookup"
|
||||||
|
MutePath = BasePathWithID + "/mute"
|
||||||
NotePath = BasePathWithID + "/note"
|
NotePath = BasePathWithID + "/note"
|
||||||
RelationshipsPath = BasePath + "/relationships"
|
RelationshipsPath = BasePath + "/relationships"
|
||||||
SearchPath = BasePath + "/search"
|
SearchPath = BasePath + "/search"
|
||||||
StatusesPath = BasePathWithID + "/statuses"
|
StatusesPath = BasePathWithID + "/statuses"
|
||||||
UnblockPath = BasePathWithID + "/unblock"
|
UnblockPath = BasePathWithID + "/unblock"
|
||||||
UnfollowPath = BasePathWithID + "/unfollow"
|
UnfollowPath = BasePathWithID + "/unfollow"
|
||||||
|
UnmutePath = BasePathWithID + "/unmute"
|
||||||
UpdatePath = BasePath + "/update_credentials"
|
UpdatePath = BasePath + "/update_credentials"
|
||||||
VerifyPath = BasePath + "/verify_credentials"
|
VerifyPath = BasePath + "/verify_credentials"
|
||||||
MovePath = BasePath + "/move"
|
MovePath = BasePath + "/move"
|
||||||
|
@ -117,6 +119,10 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H
|
||||||
// account note
|
// account note
|
||||||
attachHandler(http.MethodPost, NotePath, m.AccountNotePOSTHandler)
|
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
|
// search for accounts
|
||||||
attachHandler(http.MethodGet, SearchPath, m.AccountSearchGETHandler)
|
attachHandler(http.MethodGet, SearchPath, m.AccountSearchGETHandler)
|
||||||
attachHandler(http.MethodGet, LookupPath, m.AccountLookupGETHandler)
|
attachHandler(http.MethodGet, LookupPath, m.AccountLookupGETHandler)
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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))
|
||||||
|
}
|
|
@ -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)
|
||||||
|
|
||||||
|
}
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MutesGETHandler swagger:operation GET /api/v1/mutes mutesGet
|
// MutesGETHandler swagger:operation GET /api/v1/mutes mutesGet
|
||||||
//
|
//
|
||||||
// Get an array of accounts that requesting account has muted.
|
// 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.
|
// The next and previous queries can be parsed from the returned Link header.
|
||||||
// Example:
|
// Example:
|
||||||
//
|
//
|
||||||
|
@ -89,6 +88,7 @@ import (
|
||||||
//
|
//
|
||||||
// responses:
|
// responses:
|
||||||
// '200':
|
// '200':
|
||||||
|
// description: List of muted accounts, including when their mutes expire (if applicable).
|
||||||
// headers:
|
// headers:
|
||||||
// Link:
|
// Link:
|
||||||
// type: string
|
// type: string
|
||||||
|
@ -96,7 +96,7 @@ import (
|
||||||
// schema:
|
// schema:
|
||||||
// type: array
|
// type: array
|
||||||
// items:
|
// items:
|
||||||
// "$ref": "#/definitions/account"
|
// "$ref": "#/definitions/mutedAccount"
|
||||||
// '400':
|
// '400':
|
||||||
// description: bad request
|
// description: bad request
|
||||||
// '401':
|
// '401':
|
||||||
|
@ -108,7 +108,8 @@ import (
|
||||||
// '500':
|
// '500':
|
||||||
// description: internal server error
|
// description: internal server error
|
||||||
func (m *Module) MutesGETHandler(c *gin.Context) {
|
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)
|
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -118,5 +119,29 @@ func (m *Module) MutesGETHandler(c *gin.Context) {
|
||||||
return
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"`
|
Fields []Field `json:"fields"`
|
||||||
// Account has been suspended by our instance.
|
// Account has been suspended by our instance.
|
||||||
Suspended bool `json:"suspended,omitempty"`
|
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.
|
// Extra profile information. Shown only if the requester owns the account being requested.
|
||||||
Source *Source `json:"source,omitempty"`
|
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`.
|
// 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"`
|
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.
|
// AccountCreateRequest models account creation parameters.
|
||||||
//
|
//
|
||||||
// swagger:parameters accountCreate
|
// swagger:parameters accountCreate
|
||||||
|
|
|
@ -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"`
|
||||||
|
}
|
|
@ -94,6 +94,8 @@ func (c *Caches) Init() {
|
||||||
c.initToken()
|
c.initToken()
|
||||||
c.initTombstone()
|
c.initTombstone()
|
||||||
c.initUser()
|
c.initUser()
|
||||||
|
c.initUserMute()
|
||||||
|
c.initUserMuteIDs()
|
||||||
c.initWebfinger()
|
c.initWebfinger()
|
||||||
c.initVisibility()
|
c.initVisibility()
|
||||||
}
|
}
|
||||||
|
@ -164,5 +166,7 @@ func (c *Caches) Sweep(threshold float64) {
|
||||||
c.GTS.Token.Trim(threshold)
|
c.GTS.Token.Trim(threshold)
|
||||||
c.GTS.Tombstone.Trim(threshold)
|
c.GTS.Tombstone.Trim(threshold)
|
||||||
c.GTS.User.Trim(threshold)
|
c.GTS.User.Trim(threshold)
|
||||||
|
c.GTS.UserMute.Trim(threshold)
|
||||||
|
c.GTS.UserMuteIDs.Trim(threshold)
|
||||||
c.Visibility.Trim(threshold)
|
c.Visibility.Trim(threshold)
|
||||||
}
|
}
|
||||||
|
|
|
@ -47,7 +47,7 @@ type GTSCaches struct {
|
||||||
// Block provides access to the gtsmodel Block (account) database cache.
|
// Block provides access to the gtsmodel Block (account) database cache.
|
||||||
Block StructCache[*gtsmodel.Block]
|
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]
|
BlockIDs SliceCache[string]
|
||||||
|
|
||||||
// BoostOfIDs provides access to the boost of IDs list database cache.
|
// 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 provides access to the gtsmodel User database cache.
|
||||||
User StructCache[*gtsmodel.User]
|
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.
|
// Webfinger provides access to the webfinger URL cache.
|
||||||
// TODO: move out of GTS caches since unrelated to DB.
|
// TODO: move out of GTS caches since unrelated to DB.
|
||||||
Webfinger *ttl.Cache[string, string] // TTL=24hr, sweep=5min
|
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() {
|
func (c *Caches) initWebfinger() {
|
||||||
// Calculate maximum cache size.
|
// Calculate maximum cache size.
|
||||||
cap := calculateCacheMax(
|
cap := calculateCacheMax(
|
||||||
|
|
|
@ -213,3 +213,8 @@ func (c *Caches) OnInvalidateUser(user *gtsmodel.User) {
|
||||||
c.Visibility.Invalidate("ItemID", user.AccountID)
|
c.Visibility.Invalidate("ItemID", user.AccountID)
|
||||||
c.Visibility.Invalidate("RequesterID", 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)
|
||||||
|
}
|
||||||
|
|
|
@ -715,3 +715,15 @@ func sizeofUser() uintptr {
|
||||||
ExternalID: exampleID,
|
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"`
|
AccountStatsMemRatio float64 `name:"account-stats-mem-ratio"`
|
||||||
ApplicationMemRatio float64 `name:"application-mem-ratio"`
|
ApplicationMemRatio float64 `name:"application-mem-ratio"`
|
||||||
BlockMemRatio float64 `name:"block-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"`
|
BoostOfIDsMemRatio float64 `name:"boost-of-ids-mem-ratio"`
|
||||||
ClientMemRatio float64 `name:"client-mem-ratio"`
|
ClientMemRatio float64 `name:"client-mem-ratio"`
|
||||||
EmojiMemRatio float64 `name:"emoji-mem-ratio"`
|
EmojiMemRatio float64 `name:"emoji-mem-ratio"`
|
||||||
|
@ -233,6 +233,8 @@ type CacheConfiguration struct {
|
||||||
TokenMemRatio float64 `name:"token-mem-ratio"`
|
TokenMemRatio float64 `name:"token-mem-ratio"`
|
||||||
TombstoneMemRatio float64 `name:"tombstone-mem-ratio"`
|
TombstoneMemRatio float64 `name:"tombstone-mem-ratio"`
|
||||||
UserMemRatio float64 `name:"user-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"`
|
WebfingerMemRatio float64 `name:"webfinger-mem-ratio"`
|
||||||
VisibilityMemRatio float64 `name:"visibility-mem-ratio"`
|
VisibilityMemRatio float64 `name:"visibility-mem-ratio"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -197,6 +197,8 @@ var Defaults = Configuration{
|
||||||
TokenMemRatio: 0.75,
|
TokenMemRatio: 0.75,
|
||||||
TombstoneMemRatio: 0.5,
|
TombstoneMemRatio: 0.5,
|
||||||
UserMemRatio: 0.25,
|
UserMemRatio: 0.25,
|
||||||
|
UserMuteMemRatio: 2,
|
||||||
|
UserMuteIDsMemRatio: 3,
|
||||||
WebfingerMemRatio: 0.1,
|
WebfingerMemRatio: 0.1,
|
||||||
VisibilityMemRatio: 2,
|
VisibilityMemRatio: 2,
|
||||||
},
|
},
|
||||||
|
|
|
@ -2917,7 +2917,7 @@ func (st *ConfigState) SetCacheBlockIDsMemRatio(v float64) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// CacheBlockIDsMemRatioFlag returns the flag name for the 'Cache.BlockIDsMemRatio' field
|
// 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
|
// GetCacheBlockIDsMemRatio safely fetches the value for global configuration 'Cache.BlockIDsMemRatio' field
|
||||||
func GetCacheBlockIDsMemRatio() float64 { return global.GetCacheBlockIDsMemRatio() }
|
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
|
// SetCacheUserMemRatio safely sets the value for global configuration 'Cache.UserMemRatio' field
|
||||||
func SetCacheUserMemRatio(v float64) { global.SetCacheUserMemRatio(v) }
|
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
|
// GetCacheWebfingerMemRatio safely fetches the Configuration value for state's 'Cache.WebfingerMemRatio' field
|
||||||
func (st *ConfigState) GetCacheWebfingerMemRatio() (v float64) {
|
func (st *ConfigState) GetCacheWebfingerMemRatio() (v float64) {
|
||||||
st.mutex.RLock()
|
st.mutex.RLock()
|
||||||
|
@ -4024,3 +4074,4 @@ func GetRequestIDHeader() string { return global.GetRequestIDHeader() }
|
||||||
|
|
||||||
// SetRequestIDHeader safely sets the value for global configuration 'RequestIDHeader' field
|
// SetRequestIDHeader safely sets the value for global configuration 'RequestIDHeader' field
|
||||||
func SetRequestIDHeader(v string) { global.SetRequestIDHeader(v) }
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
|
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
|
||||||
|
@ -108,6 +109,16 @@ func (r *relationshipDB) GetRelationship(ctx context.Context, requestingAccount
|
||||||
rel.Note = note.Comment
|
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
|
return &rel, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
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() {
|
func (suite *RelationshipTestSuite) TestGetRelationship() {
|
||||||
requestingAccount := suite.testAccounts["local_account_1"]
|
requestingAccount := suite.testAccounts["local_account_1"]
|
||||||
targetAccount := suite.testAccounts["admin_account"]
|
targetAccount := suite.testAccounts["admin_account"]
|
||||||
|
|
|
@ -187,4 +187,25 @@ type Relationship interface {
|
||||||
|
|
||||||
// PopulateNote populates the struct pointers on the given note.
|
// PopulateNote populates the struct pointers on the given note.
|
||||||
PopulateNote(ctx context.Context, note *gtsmodel.AccountNote) error
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
|
@ -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.
|
// 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 {
|
if err != nil {
|
||||||
log.Errorf(ctx, "error converting bookmarked status to api: %s", err)
|
log.Errorf(ctx, "error converting bookmarked status to api: %s", err)
|
||||||
continue
|
continue
|
||||||
|
|
|
@ -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 {
|
for _, s := range filtered {
|
||||||
// Convert filtered statuses to API statuses.
|
// 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 {
|
if err != nil {
|
||||||
log.Errorf(ctx, "error convering to api status: %v", err)
|
log.Errorf(ctx, "error convering to api status: %v", err)
|
||||||
continue
|
continue
|
||||||
|
|
|
@ -185,7 +185,7 @@ func (p *Processor) GetAPIStatus(
|
||||||
apiStatus *apimodel.Status,
|
apiStatus *apimodel.Status,
|
||||||
errWithCode gtserror.WithCode,
|
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 {
|
if err != nil {
|
||||||
err = gtserror.Newf("error converting status: %w", err)
|
err = gtserror.Newf("error converting status: %w", err)
|
||||||
return nil, gtserror.NewErrorInternalError(err)
|
return nil, gtserror.NewErrorInternalError(err)
|
||||||
|
|
|
@ -114,7 +114,7 @@ func (p *Processor) packageStatuses(
|
||||||
continue
|
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 {
|
if err != nil {
|
||||||
log.Debugf(ctx, "skipping status %s because it couldn't be converted to its api representation: %s", status.ID, err)
|
log.Debugf(ctx, "skipping status %s because it couldn't be converted to its api representation: %s", status.ID, err)
|
||||||
continue
|
continue
|
||||||
|
|
|
@ -24,6 +24,8 @@ import (
|
||||||
|
|
||||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||||
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
|
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/gtserror"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
"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)
|
err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requestingAccount.ID, err)
|
||||||
return nil, gtserror.NewErrorInternalError(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) {
|
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)
|
return p.contextGet(ctx, requestingAccount, targetStatusID, convert)
|
||||||
}
|
}
|
||||||
|
|
|
@ -40,7 +40,7 @@ func (suite *StatusUpdateTestSuite) TestStreamNotification() {
|
||||||
suite.NoError(errWithCode)
|
suite.NoError(errWithCode)
|
||||||
|
|
||||||
editedStatus := suite.testStatuses["remote_account_1_status_1"]
|
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.NoError(err)
|
||||||
|
|
||||||
suite.streamProcessor.StatusUpdate(context.Background(), account, apiStatus, stream.TimelineHome)
|
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
|
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 {
|
if err != nil {
|
||||||
log.Errorf(ctx, "error convering to api status: %v", err)
|
log.Errorf(ctx, "error convering to api status: %v", err)
|
||||||
continue
|
continue
|
||||||
|
|
|
@ -24,7 +24,9 @@ import (
|
||||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
|
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/filter/visibility"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||||
|
@ -105,7 +107,14 @@ func HomeTimelineStatusPrepare(state *state.State, converter *typeutils.Converte
|
||||||
return nil, err
|
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"
|
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
|
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/filter/visibility"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||||
|
@ -117,7 +119,14 @@ func ListTimelineStatusPrepare(state *state.State, converter *typeutils.Converte
|
||||||
return nil, err
|
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"
|
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
"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/gtserror"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
"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)
|
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 (
|
var (
|
||||||
items = make([]interface{}, 0, count)
|
items = make([]interface{}, 0, count)
|
||||||
nextMaxIDValue string
|
nextMaxIDValue string
|
||||||
|
@ -76,9 +86,11 @@ func (p *Processor) NotificationsGet(ctx context.Context, authed *oauth.Auth, ma
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
item, err := p.converter.NotificationToAPINotification(ctx, n, filters)
|
item, err := p.converter.NotificationToAPINotification(ctx, n, filters, compiledMutes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if !errors.Is(err, status.ErrHideStatus) {
|
||||||
log.Debugf(ctx, "skipping notification %s because it couldn't be converted to its api representation: %s", n.ID, err)
|
log.Debugf(ctx, "skipping notification %s because it couldn't be converted to its api representation: %s", n.ID, err)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -116,7 +128,14 @@ func (p *Processor) NotificationGet(ctx context.Context, account *gtsmodel.Accou
|
||||||
return nil, gtserror.NewErrorInternalError(err)
|
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 err != nil {
|
||||||
if errors.Is(err, db.ErrNoEntries) {
|
if errors.Is(err, db.ErrNoEntries) {
|
||||||
return nil, gtserror.NewErrorNotFound(err)
|
return nil, gtserror.NewErrorNotFound(err)
|
||||||
|
|
|
@ -25,6 +25,8 @@ import (
|
||||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
|
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/gtserror"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||||
|
@ -48,6 +50,7 @@ func (p *Processor) PublicTimelineGet(
|
||||||
)
|
)
|
||||||
|
|
||||||
var filters []*gtsmodel.Filter
|
var filters []*gtsmodel.Filter
|
||||||
|
var compiledMutes *usermute.CompiledUserMuteList
|
||||||
if requester != nil {
|
if requester != nil {
|
||||||
var err error
|
var err error
|
||||||
filters, err = p.state.DB.GetFiltersForAccountID(ctx, requester.ID)
|
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)
|
err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requester.ID, err)
|
||||||
return nil, gtserror.NewErrorInternalError(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
|
// Try a few times to select appropriate public
|
||||||
|
@ -98,7 +108,7 @@ outer:
|
||||||
continue inner
|
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) {
|
if errors.Is(err, statusfilter.ErrHideStatus) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,6 +25,8 @@ import (
|
||||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
|
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/gtserror"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||||
|
@ -118,6 +120,13 @@ func (p *Processor) packageTagResponse(
|
||||||
return nil, gtserror.NewErrorInternalError(err)
|
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 {
|
for _, s := range statuses {
|
||||||
timelineable, err := p.filter.StatusTagTimelineable(ctx, requestingAcct, s)
|
timelineable, err := p.filter.StatusTagTimelineable(ctx, requestingAcct, s)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -129,7 +138,7 @@ func (p *Processor) packageTagResponse(
|
||||||
continue
|
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) {
|
if errors.Is(err, statusfilter.ErrHideStatus) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
|
@ -157,6 +157,7 @@ func (suite *FromClientAPITestSuite) statusJSON(
|
||||||
requestingAccount,
|
requestingAccount,
|
||||||
statusfilter.FilterContextNone,
|
statusfilter.FilterContextNone,
|
||||||
nil,
|
nil,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
suite.FailNow(err.Error())
|
suite.FailNow(err.Error())
|
||||||
|
@ -261,7 +262,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithNotification() {
|
||||||
suite.FailNow("timed out waiting for new status notification")
|
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 {
|
if err != nil {
|
||||||
suite.FailNow(err.Error())
|
suite.FailNow(err.Error())
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,6 +23,8 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
"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/gtscontext"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"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)
|
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 {
|
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)
|
return gtserror.Newf("error converting notification to api representation: %w", err)
|
||||||
}
|
}
|
||||||
s.Stream.Notify(ctx, targetAccount, apiNotif)
|
s.Stream.Notify(ctx, targetAccount, apiNotif)
|
||||||
|
|
|
@ -23,6 +23,7 @@ import (
|
||||||
|
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
|
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/gtscontext"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"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)
|
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
|
// Add status to any relevant lists
|
||||||
// for this follow, if applicable.
|
// for this follow, if applicable.
|
||||||
s.listTimelineStatusForFollow(
|
s.listTimelineStatusForFollow(
|
||||||
|
@ -125,6 +132,7 @@ func (s *Surface) timelineAndNotifyStatusForFollowers(
|
||||||
follow,
|
follow,
|
||||||
&errs,
|
&errs,
|
||||||
filters,
|
filters,
|
||||||
|
compiledMutes,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add status to home timeline for owner
|
// Add status to home timeline for owner
|
||||||
|
@ -137,6 +145,7 @@ func (s *Surface) timelineAndNotifyStatusForFollowers(
|
||||||
status,
|
status,
|
||||||
stream.TimelineHome,
|
stream.TimelineHome,
|
||||||
filters,
|
filters,
|
||||||
|
compiledMutes,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errs.Appendf("error home timelining status: %w", err)
|
errs.Appendf("error home timelining status: %w", err)
|
||||||
|
@ -189,6 +198,7 @@ func (s *Surface) listTimelineStatusForFollow(
|
||||||
follow *gtsmodel.Follow,
|
follow *gtsmodel.Follow,
|
||||||
errs *gtserror.MultiError,
|
errs *gtserror.MultiError,
|
||||||
filters []*gtsmodel.Filter,
|
filters []*gtsmodel.Filter,
|
||||||
|
mutes *usermute.CompiledUserMuteList,
|
||||||
) {
|
) {
|
||||||
// To put this status in appropriate list timelines,
|
// To put this status in appropriate list timelines,
|
||||||
// we need to get each listEntry that pertains to
|
// we need to get each listEntry that pertains to
|
||||||
|
@ -232,6 +242,7 @@ func (s *Surface) listTimelineStatusForFollow(
|
||||||
status,
|
status,
|
||||||
stream.TimelineList+":"+listEntry.ListID, // key streamType to this specific list
|
stream.TimelineList+":"+listEntry.ListID, // key streamType to this specific list
|
||||||
filters,
|
filters,
|
||||||
|
mutes,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
errs.Appendf("error adding status to timeline for list %s: %w", listEntry.ListID, err)
|
errs.Appendf("error adding status to timeline for list %s: %w", listEntry.ListID, err)
|
||||||
// implicit continue
|
// implicit continue
|
||||||
|
@ -343,6 +354,7 @@ func (s *Surface) timelineStatus(
|
||||||
status *gtsmodel.Status,
|
status *gtsmodel.Status,
|
||||||
streamType string,
|
streamType string,
|
||||||
filters []*gtsmodel.Filter,
|
filters []*gtsmodel.Filter,
|
||||||
|
mutes *usermute.CompiledUserMuteList,
|
||||||
) (bool, error) {
|
) (bool, error) {
|
||||||
// Ingest status into given timeline using provided function.
|
// Ingest status into given timeline using provided function.
|
||||||
if inserted, err := ingest(ctx, timelineID, status); err != nil {
|
if inserted, err := ingest(ctx, timelineID, status); err != nil {
|
||||||
|
@ -359,6 +371,7 @@ func (s *Surface) timelineStatus(
|
||||||
account,
|
account,
|
||||||
statusfilter.FilterContextHome,
|
statusfilter.FilterContextHome,
|
||||||
filters,
|
filters,
|
||||||
|
mutes,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = gtserror.Newf("error converting status %s to frontend representation: %w", status.ID, err)
|
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)
|
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
|
// Add status to any relevant lists
|
||||||
// for this follow, if applicable.
|
// for this follow, if applicable.
|
||||||
s.listTimelineStatusUpdateForFollow(
|
s.listTimelineStatusUpdateForFollow(
|
||||||
|
@ -486,6 +505,7 @@ func (s *Surface) timelineStatusUpdateForFollowers(
|
||||||
follow,
|
follow,
|
||||||
&errs,
|
&errs,
|
||||||
filters,
|
filters,
|
||||||
|
compiledMutes,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add status to home timeline for owner
|
// Add status to home timeline for owner
|
||||||
|
@ -496,6 +516,7 @@ func (s *Surface) timelineStatusUpdateForFollowers(
|
||||||
status,
|
status,
|
||||||
stream.TimelineHome,
|
stream.TimelineHome,
|
||||||
filters,
|
filters,
|
||||||
|
compiledMutes,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errs.Appendf("error home timelining status: %w", err)
|
errs.Appendf("error home timelining status: %w", err)
|
||||||
|
@ -514,6 +535,7 @@ func (s *Surface) listTimelineStatusUpdateForFollow(
|
||||||
follow *gtsmodel.Follow,
|
follow *gtsmodel.Follow,
|
||||||
errs *gtserror.MultiError,
|
errs *gtserror.MultiError,
|
||||||
filters []*gtsmodel.Filter,
|
filters []*gtsmodel.Filter,
|
||||||
|
mutes *usermute.CompiledUserMuteList,
|
||||||
) {
|
) {
|
||||||
// To put this status in appropriate list timelines,
|
// To put this status in appropriate list timelines,
|
||||||
// we need to get each listEntry that pertains to
|
// we need to get each listEntry that pertains to
|
||||||
|
@ -555,6 +577,7 @@ func (s *Surface) listTimelineStatusUpdateForFollow(
|
||||||
status,
|
status,
|
||||||
stream.TimelineList+":"+listEntry.ListID, // key streamType to this specific list
|
stream.TimelineList+":"+listEntry.ListID, // key streamType to this specific list
|
||||||
filters,
|
filters,
|
||||||
|
mutes,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
errs.Appendf("error adding status to timeline for list %s: %w", listEntry.ListID, err)
|
errs.Appendf("error adding status to timeline for list %s: %w", listEntry.ListID, err)
|
||||||
// implicit continue
|
// implicit continue
|
||||||
|
@ -570,8 +593,9 @@ func (s *Surface) timelineStreamStatusUpdate(
|
||||||
status *gtsmodel.Status,
|
status *gtsmodel.Status,
|
||||||
streamType string,
|
streamType string,
|
||||||
filters []*gtsmodel.Filter,
|
filters []*gtsmodel.Filter,
|
||||||
|
mutes *usermute.CompiledUserMuteList,
|
||||||
) error {
|
) 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) {
|
if errors.Is(err, statusfilter.ErrHideStatus) {
|
||||||
// Don't put this status in the stream.
|
// Don't put this status in the stream.
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -20,6 +20,7 @@ package typeutils
|
||||||
import (
|
import (
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -27,11 +28,13 @@ type Converter struct {
|
||||||
state *state.State
|
state *state.State
|
||||||
defaultAvatars []string
|
defaultAvatars []string
|
||||||
randAvatars sync.Map
|
randAvatars sync.Map
|
||||||
|
filter *visibility.Filter
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewConverter(state *state.State) *Converter {
|
func NewConverter(state *state.State) *Converter {
|
||||||
return &Converter{
|
return &Converter{
|
||||||
state: state,
|
state: state,
|
||||||
defaultAvatars: populateDefaultAvatars(),
|
defaultAvatars: populateDefaultAvatars(),
|
||||||
|
filter: visibility.NewFilter(state),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,6 +31,7 @@ import (
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
|
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/gtserror"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/language"
|
"github.com/superseriousbusiness/gotosocial/internal/language"
|
||||||
|
@ -741,8 +742,9 @@ func (c *Converter) StatusToAPIStatus(
|
||||||
requestingAccount *gtsmodel.Account,
|
requestingAccount *gtsmodel.Account,
|
||||||
filterContext statusfilter.FilterContext,
|
filterContext statusfilter.FilterContext,
|
||||||
filters []*gtsmodel.Filter,
|
filters []*gtsmodel.Filter,
|
||||||
|
mutes *usermute.CompiledUserMuteList,
|
||||||
) (*apimodel.Status, error) {
|
) (*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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -757,7 +759,7 @@ func (c *Converter) StatusToAPIStatus(
|
||||||
return apiStatus, nil
|
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.
|
// The result may be nil if no filters matched.
|
||||||
// If the status should not be returned at all, it returns the ErrHideStatus error.
|
// If the status should not be returned at all, it returns the ErrHideStatus error.
|
||||||
func (c *Converter) statusToAPIFilterResults(
|
func (c *Converter) statusToAPIFilterResults(
|
||||||
|
@ -766,14 +768,71 @@ func (c *Converter) statusToAPIFilterResults(
|
||||||
requestingAccount *gtsmodel.Account,
|
requestingAccount *gtsmodel.Account,
|
||||||
filterContext statusfilter.FilterContext,
|
filterContext statusfilter.FilterContext,
|
||||||
filters []*gtsmodel.Filter,
|
filters []*gtsmodel.Filter,
|
||||||
|
mutes *usermute.CompiledUserMuteList,
|
||||||
) ([]apimodel.FilterResult, error) {
|
) ([]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
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
filterResults := make([]apimodel.FilterResult, 0, len(filters))
|
// Both mutes and filters can expire.
|
||||||
|
|
||||||
now := time.Now()
|
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 {
|
for _, filter := range filters {
|
||||||
if !filterAppliesInContext(filter, filterContext) {
|
if !filterAppliesInContext(filter, filterContext) {
|
||||||
// Filter doesn't apply to this context.
|
// Filter doesn't apply to this context.
|
||||||
|
@ -893,7 +952,7 @@ func (c *Converter) StatusToWebStatus(
|
||||||
s *gtsmodel.Status,
|
s *gtsmodel.Status,
|
||||||
requestingAccount *gtsmodel.Account,
|
requestingAccount *gtsmodel.Account,
|
||||||
) (*apimodel.Status, error) {
|
) (*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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -997,6 +1056,7 @@ func (c *Converter) statusToFrontend(
|
||||||
requestingAccount *gtsmodel.Account,
|
requestingAccount *gtsmodel.Account,
|
||||||
filterContext statusfilter.FilterContext,
|
filterContext statusfilter.FilterContext,
|
||||||
filters []*gtsmodel.Filter,
|
filters []*gtsmodel.Filter,
|
||||||
|
mutes *usermute.CompiledUserMuteList,
|
||||||
) (*apimodel.Status, error) {
|
) (*apimodel.Status, error) {
|
||||||
// Try to populate status struct pointer fields.
|
// Try to populate status struct pointer fields.
|
||||||
// We can continue in many cases of partial failure,
|
// We can continue in many cases of partial failure,
|
||||||
|
@ -1095,7 +1155,7 @@ func (c *Converter) statusToFrontend(
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.BoostOf != nil {
|
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 errors.Is(err, statusfilter.ErrHideStatus) {
|
||||||
// If we'd hide the original status, hide the boost.
|
// If we'd hide the original status, hide the boost.
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -1164,8 +1224,11 @@ func (c *Converter) statusToFrontend(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply filters.
|
// 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 err != nil {
|
||||||
|
if errors.Is(err, statusfilter.ErrHideStatus) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("error applying filters: %w", err)
|
return nil, fmt.Errorf("error applying filters: %w", err)
|
||||||
}
|
}
|
||||||
apiStatus.Filtered = filterResults
|
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
|
// 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 {
|
if n.TargetAccount == nil {
|
||||||
tAccount, err := c.state.DB.GetAccountByID(ctx, n.TargetAccountID)
|
tAccount, err := c.state.DB.GetAccountByID(ctx, n.TargetAccountID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -1494,8 +1562,11 @@ func (c *Converter) NotificationToAPINotification(ctx context.Context, n *gtsmod
|
||||||
}
|
}
|
||||||
|
|
||||||
var err error
|
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 err != nil {
|
||||||
|
if errors.Is(err, statusfilter.ErrHideStatus) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("NotificationToapi: error converting status to api: %s", 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 {
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ReportToAdminAPIReport: error converting status with id %s to api status: %w", s.ID, err)
|
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/config"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
|
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/gtsmodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -428,7 +430,7 @@ func (suite *InternalToFrontendTestSuite) TestLocalInstanceAccountToFrontendBloc
|
||||||
func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() {
|
func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() {
|
||||||
testStatus := suite.testStatuses["admin_account_status_1"]
|
testStatus := suite.testStatuses["admin_account_status_1"]
|
||||||
requestingAccount := suite.testAccounts["local_account_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)
|
suite.NoError(err)
|
||||||
|
|
||||||
b, err := json.MarshalIndent(apiStatus, "", " ")
|
b, err := json.MarshalIndent(apiStatus, "", " ")
|
||||||
|
@ -556,6 +558,7 @@ func (suite *InternalToFrontendTestSuite) TestWarnFilteredStatusToFrontend() {
|
||||||
requestingAccount,
|
requestingAccount,
|
||||||
statusfilter.FilterContextHome,
|
statusfilter.FilterContextHome,
|
||||||
requestingAccountFilters,
|
requestingAccountFilters,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
suite.NoError(err)
|
suite.NoError(err)
|
||||||
|
|
||||||
|
@ -711,6 +714,60 @@ func (suite *InternalToFrontendTestSuite) TestHideFilteredStatusToFrontend() {
|
||||||
requestingAccount,
|
requestingAccount,
|
||||||
statusfilter.FilterContextHome,
|
statusfilter.FilterContextHome,
|
||||||
requestingAccountFilters,
|
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)
|
suite.ErrorIs(err, statusfilter.ErrHideStatus)
|
||||||
}
|
}
|
||||||
|
@ -719,7 +776,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownAttachments
|
||||||
testStatus := suite.testStatuses["remote_account_2_status_1"]
|
testStatus := suite.testStatuses["remote_account_2_status_1"]
|
||||||
requestingAccount := suite.testAccounts["admin_account"]
|
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)
|
suite.NoError(err)
|
||||||
|
|
||||||
b, err := json.MarshalIndent(apiStatus, "", " ")
|
b, err := json.MarshalIndent(apiStatus, "", " ")
|
||||||
|
@ -952,7 +1009,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownLanguage()
|
||||||
*testStatus = *suite.testStatuses["admin_account_status_1"]
|
*testStatus = *suite.testStatuses["admin_account_status_1"]
|
||||||
testStatus.Language = ""
|
testStatus.Language = ""
|
||||||
requestingAccount := suite.testAccounts["local_account_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)
|
suite.NoError(err)
|
||||||
|
|
||||||
b, err := json.MarshalIndent(apiStatus, "", " ")
|
b, err := json.MarshalIndent(apiStatus, "", " ")
|
||||||
|
|
|
@ -28,7 +28,8 @@ EXPECT=$(cat << "EOF"
|
||||||
"account-settings-mem-ratio": 0.1,
|
"account-settings-mem-ratio": 0.1,
|
||||||
"account-stats-mem-ratio": 2,
|
"account-stats-mem-ratio": 2,
|
||||||
"application-mem-ratio": 0.1,
|
"application-mem-ratio": 0.1,
|
||||||
"block-mem-ratio": 3,
|
"block-ids-mem-ratio": 3,
|
||||||
|
"block-mem-ratio": 2,
|
||||||
"boost-of-ids-mem-ratio": 3,
|
"boost-of-ids-mem-ratio": 3,
|
||||||
"client-mem-ratio": 0.1,
|
"client-mem-ratio": 0.1,
|
||||||
"emoji-category-mem-ratio": 0.1,
|
"emoji-category-mem-ratio": 0.1,
|
||||||
|
@ -64,6 +65,8 @@ EXPECT=$(cat << "EOF"
|
||||||
"token-mem-ratio": 0.75,
|
"token-mem-ratio": 0.75,
|
||||||
"tombstone-mem-ratio": 0.5,
|
"tombstone-mem-ratio": 0.5,
|
||||||
"user-mem-ratio": 0.25,
|
"user-mem-ratio": 0.25,
|
||||||
|
"user-mute-ids-mem-ratio": 3,
|
||||||
|
"user-mute-mem-ratio": 2,
|
||||||
"visibility-mem-ratio": 2,
|
"visibility-mem-ratio": 2,
|
||||||
"webfinger-mem-ratio": 0.1
|
"webfinger-mem-ratio": 0.1
|
||||||
},
|
},
|
||||||
|
|
|
@ -56,6 +56,7 @@ var testModels = []interface{}{
|
||||||
>smodel.ThreadMute{},
|
>smodel.ThreadMute{},
|
||||||
>smodel.ThreadToStatus{},
|
>smodel.ThreadToStatus{},
|
||||||
>smodel.User{},
|
>smodel.User{},
|
||||||
|
>smodel.UserMute{},
|
||||||
>smodel.Emoji{},
|
>smodel.Emoji{},
|
||||||
>smodel.Instance{},
|
>smodel.Instance{},
|
||||||
>smodel.Notification{},
|
>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 {
|
if err := db.CreateInstanceAccount(ctx); err != nil {
|
||||||
log.Panic(nil, err)
|
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.
|
// 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) {
|
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
|
// convert the activity into json bytes
|
||||||
|
|
Loading…
Reference in New Issue