Browse Source
* 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>pull/2971/head
47 changed files with 2346 additions and 53 deletions
@ -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)) |
||||
} |
||||
@ -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()) |
||||
} |
||||
} |
||||
@ -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"` |
||||
} |
||||
@ -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) |
||||
} |
||||
} |
||||
@ -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 |
||||
}) |
||||
} |
||||
@ -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) |
||||
} |
||||
@ -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 |
||||
} |
||||
Loading…
Reference in new issue