diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml
index 75fa2a777..563a0c16f 100644
--- a/docs/api/swagger.yaml
+++ b/docs/api/swagger.yaml
@@ -8403,7 +8403,7 @@ paths:
type: file
- description: |-
Type of entries contained in the data file:
- - `following` - accounts to follow. - `blocks` - accounts to block.
+ - `following` - accounts to follow. - `blocks` - accounts to block. - `mutes` - accounts to mute.
in: formData
name: type
required: true
diff --git a/docs/user_guide/settings.md b/docs/user_guide/settings.md
index 17f2c5962..691181cb9 100644
--- a/docs/user_guide/settings.md
+++ b/docs/user_guide/settings.md
@@ -266,3 +266,6 @@ Both merge and overwrite operations are idempotent, which basically means that d
!!! info
For a variety of reasons, it will not always be possible to recreate every entry in an uploaded CSV file via importing. For example, say you are trying to import a CSV of follows containing `example_account`, but `example_account`'s instance has gone offline, or their instance blocks yours, or your instance blocks theirs, etc. In this case, the follow of `example_account` would not be created.
+
+!!! warning
+ The CSV format for mutes does not contain expiration data, so temporary mutes are exported (and imported) as permanent mutes.
diff --git a/internal/api/client/import/import.go b/internal/api/client/import/import.go
index c3908625b..8e2dde0c9 100644
--- a/internal/api/client/import/import.go
+++ b/internal/api/client/import/import.go
@@ -25,6 +25,7 @@ import (
"strings"
"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"
@@ -38,6 +39,7 @@ const (
var types = []string{
"following",
"blocks",
+ "mutes",
}
var modes = []string{
@@ -93,6 +95,8 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H
//
// - `following` - accounts to follow.
// - `blocks` - accounts to block.
+// - `mutes` - accounts to mute.
+//
// type: string
// required: true
// -
diff --git a/internal/api/client/import/import_test.go b/internal/api/client/import/import_test.go
index fba83e1a3..56497d27d 100644
--- a/internal/api/client/import/import_test.go
+++ b/internal/api/client/import/import_test.go
@@ -26,6 +26,7 @@ import (
"testing"
"github.com/stretchr/testify/suite"
+
importdata "github.com/superseriousbusiness/gotosocial/internal/api/client/import"
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
@@ -206,6 +207,97 @@ admin@localhost:8080,true
}
}
+func (suite *ImportTestSuite) TestImportMutes() {
+ var (
+ ctx = context.Background()
+ testAccount = suite.testAccounts["local_account_1"]
+ )
+
+ // Clear existing mutes from Zork.
+ if err := suite.state.DB.DeleteAccountMutes(ctx, testAccount.ID); err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // Have zork mute turtle, admin and remote fossbro.
+ data := `Account address,Hide notifications
+admin@localhost:8080,true
+unknown@localhost:8080,true
+1happyturtle@localhost:8080,false
+foss_satan@fossbros-anonymous.io,true
+`
+
+ // Trigger the import handler.
+ suite.TriggerHandler(data, "mutes", "merge")
+
+ // Wait for mutes to be applied
+ if !testrig.WaitFor(func() bool {
+ mutes, err := suite.state.DB.GetAccountMutes(ctx, testAccount.ID, nil)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ for _, m := range mutes {
+ switch m.TargetAccount.ID {
+ case suite.testAccounts["remote_account_1"].ID:
+ if *m.Notifications != true {
+ suite.FailNow("expected notifications from fossbro to be muted")
+ }
+ case suite.testAccounts["admin_account"].ID:
+ if *m.Notifications != true {
+ suite.FailNow("expected notifications from admin to be muted")
+ }
+ case suite.testAccounts["local_account_2"].ID:
+ if *m.Notifications != false {
+ suite.FailNow("expected notifications from turtle to NOT be muted")
+ }
+ default:
+ suite.FailNow("unexpected muted account", m.TargetAccount)
+ }
+ }
+ return len(mutes) == 3
+ }) {
+ suite.FailNow("timed out waiting for mutes to apply")
+ }
+
+ // Import again in overwrite mode:
+ // - remote fossbro is unmuted, admin and turtle are kept
+ // - Notification hiding is reversed to confirm mutes are modified
+ data = `Account address,Hide notifications
+admin@localhost:8080,false
+1happyturtle@localhost:8080,true
+`
+
+ // Trigger the import handler.
+ suite.TriggerHandler(data, "mutes", "overwrite")
+
+ // Wait for mutes to be applied
+ if !testrig.WaitFor(func() bool {
+ mutes, err := suite.state.DB.GetAccountMutes(ctx, testAccount.ID, nil)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ for _, m := range mutes {
+ switch m.TargetAccount.ID {
+ case suite.testAccounts["remote_account_1"].ID:
+ suite.FailNow("fossbro is still muted")
+ case suite.testAccounts["admin_account"].ID:
+ if *m.Notifications != false {
+ suite.FailNow("expected notifications from admin to be NOT muted")
+ }
+ case suite.testAccounts["local_account_2"].ID:
+ if *m.Notifications != true {
+ suite.FailNow("expected notifications from turtle to be muted")
+ }
+ default:
+ suite.FailNow("unexpected muted account", m.TargetAccount)
+ }
+ }
+ return len(mutes) == 2
+ }) {
+ suite.FailNow("timed out waiting for import to apply")
+ }
+
+}
+
func TestImportTestSuite(t *testing.T) {
suite.Run(t, new(ImportTestSuite))
}
diff --git a/internal/processing/account/import.go b/internal/processing/account/import.go
index 68e843cfa..5c830639a 100644
--- a/internal/processing/account/import.go
+++ b/internal/processing/account/import.go
@@ -55,6 +55,14 @@ func (p *Processor) ImportData(
overwrite,
)
+ case "mutes":
+ return p.importMutes(
+ ctx,
+ requester,
+ data,
+ overwrite,
+ )
+
default:
const text = "import type not yet supported"
return gtserror.NewErrorUnprocessableEntity(errors.New(text), text)
@@ -377,3 +385,150 @@ func importBlocksAsyncF(
}
}
}
+
+func (p *Processor) importMutes(
+ ctx context.Context,
+ requester *gtsmodel.Account,
+ mutesData *multipart.FileHeader,
+ overwrite bool,
+) gtserror.WithCode {
+ file, err := mutesData.Open()
+ if err != nil {
+ err := fmt.Errorf("error opening mutes data file: %w", err)
+ return gtserror.NewErrorBadRequest(err, err.Error())
+ }
+ defer file.Close()
+
+ // Parse records out of the file.
+ records, err := csv.NewReader(file).ReadAll()
+ if err != nil {
+ err := fmt.Errorf("error reading mutes data file: %w", err)
+ return gtserror.NewErrorBadRequest(err, err.Error())
+ }
+
+ // Convert the records into a slice of barebones mutes.
+ //
+ // Only TargetAccount.Username, TargetAccount.Domain,
+ // and Notifications will be set on each mute.
+ mutes, err := p.converter.CSVToMutes(ctx, records)
+ if err != nil {
+ err := fmt.Errorf("error converting records to mutes: %w", err)
+ return gtserror.NewErrorBadRequest(err, err.Error())
+ }
+
+ // Do remaining processing of this import asynchronously.
+ f := importMutesAsyncF(p, requester, mutes, overwrite)
+ p.state.Workers.Processing.Queue.Push(f)
+
+ return nil
+}
+
+func importMutesAsyncF(
+ p *Processor,
+ requester *gtsmodel.Account,
+ mutes []*gtsmodel.UserMute,
+ overwrite bool,
+) func(context.Context) {
+ return func(ctx context.Context) {
+ // Map used to store wanted
+ // mute targets (if overwriting).
+ var wantedMutes map[string]struct{}
+
+ if overwrite {
+ // If we're overwriting, we need to get current
+ // mutes owned by requester *before* making any
+ // changes, so that we can remove unwanted mutes
+ // after we've created new ones.
+ var (
+ prevMutes []*gtsmodel.UserMute
+ err error
+ )
+
+ prevMutes, err = p.state.DB.GetAccountMutes(ctx, requester.ID, nil)
+ if err != nil {
+ log.Errorf(ctx, "db error getting mutes: %v", err)
+ return
+ }
+
+ // Initialize new mutes map.
+ wantedMutes = make(map[string]struct{}, len(mutes))
+
+ // Once we've created (or tried to create)
+ // the required mutes, go through previous
+ // mutes and remove unwanted ones.
+ defer func() {
+ for _, prev := range prevMutes {
+ username := prev.TargetAccount.Username
+ domain := prev.TargetAccount.Domain
+
+ _, wanted := wantedMutes[username+"@"+domain]
+ if wanted {
+ // Leave this
+ // one alone.
+ continue
+ }
+
+ if _, errWithCode := p.MuteRemove(
+ ctx,
+ requester,
+ prev.TargetAccountID,
+ ); errWithCode != nil {
+ log.Errorf(ctx, "could not unmute account: %v", errWithCode.Unwrap())
+ continue
+ }
+ }
+ }()
+ }
+
+ // Go through the mutes parsed from CSV
+ // file, and create / update each one.
+ for _, mute := range mutes {
+ var (
+ // Username of the target.
+ username = mute.TargetAccount.Username
+
+ // Domain of the target.
+ // Empty for our domain.
+ domain = mute.TargetAccount.Domain
+ )
+
+ if overwrite {
+ // We'll be overwriting, so store
+ // this new mute in our handy map.
+ wantedMutes[username+"@"+domain] = struct{}{}
+ }
+
+ // Get the target account, dereferencing it if necessary.
+ targetAcct, _, err := p.federator.Dereferencer.GetAccountByUsernameDomain(
+ ctx,
+ // Provide empty request user to use the
+ // instance account to deref the account.
+ //
+ // It's pointless to make lots of calls
+ // to a remote from an account that's about
+ // to mute that account.
+ "",
+ username,
+ domain,
+ )
+ if err != nil {
+ log.Errorf(ctx, "could not retrieve account: %v", err)
+ continue
+ }
+
+ // Use the processor's MuteCreate function
+ // to create or update the mute. This takes
+ // account of existing mutes, and also sends
+ // the mute to the FromClientAPI processor.
+ if _, errWithCode := p.MuteCreate(
+ ctx,
+ requester,
+ targetAcct.ID,
+ &apimodel.UserMuteCreateUpdateRequest{Notifications: mute.Notifications},
+ ); errWithCode != nil {
+ log.Errorf(ctx, "could not mute account: %v", errWithCode.Unwrap())
+ continue
+ }
+ }
+ }
+}
diff --git a/internal/typeutils/csv.go b/internal/typeutils/csv.go
index 7211d5c9c..aff556021 100644
--- a/internal/typeutils/csv.go
+++ b/internal/typeutils/csv.go
@@ -553,3 +553,96 @@ func (c *Converter) CSVToBlocks(
return blocks, nil
}
+
+// CSVToMutes converts a slice of CSV records
+// to a slice of barebones *gtsmodel.UserMute's,
+// ready for further processing.
+//
+// Only TargetAccount.Username, TargetAccount.Domain,
+// and Notifications will be set on each mute.
+//
+// The CSV format does not hold expiration data, so
+// all imported mutes will be permanent, possibly
+// overwriting existing temporary mutes.
+func (c *Converter) CSVToMutes(
+ ctx context.Context,
+ records [][]string,
+) ([]*gtsmodel.UserMute, error) {
+ // We need to know our own domain for this.
+ // Try account domain, fall back to host.
+ var (
+ thisHost = config.GetHost()
+ thisAccountDomain = config.GetAccountDomain()
+ mutes = make([]*gtsmodel.UserMute, 0, len(records)-1)
+ )
+
+ for _, record := range records {
+ recordLen := len(record)
+
+ // Older versions of this Masto CSV
+ // schema may not include "Hide notifications",
+ // so be lenient here in what we accept.
+ if recordLen == 0 ||
+ recordLen > 2 {
+ // Badly formatted,
+ // skip this one.
+ continue
+ }
+
+ // "Account address"
+ namestring := record[0]
+ if namestring == "" {
+ // Badly formatted,
+ // skip this one.
+ continue
+ }
+
+ if namestring == "Account address" {
+ // CSV header row,
+ // skip this one.
+ continue
+ }
+
+ // Prepend with "@"
+ // if not included.
+ if namestring[0] != '@' {
+ namestring = "@" + namestring
+ }
+
+ username, domain, err := util.ExtractNamestringParts(namestring)
+ if err != nil {
+ // Badly formatted,
+ // skip this one.
+ continue
+ }
+
+ if domain == thisHost || domain == thisAccountDomain {
+ // Clear the domain,
+ // since it's ours.
+ domain = ""
+ }
+
+ // "Hide notifications"
+ var hideNotifications *bool
+ if recordLen > 1 {
+ b, err := strconv.ParseBool(record[1])
+ if err != nil {
+ // Badly formatted,
+ // skip this one.
+ continue
+ }
+ hideNotifications = &b
+ }
+
+ // Looks good, whack it in the slice.
+ mutes = append(mutes, >smodel.UserMute{
+ TargetAccount: >smodel.Account{
+ Username: username,
+ Domain: domain,
+ },
+ Notifications: hideNotifications,
+ })
+ }
+
+ return mutes, nil
+}
diff --git a/web/source/settings/views/user/export-import/import.tsx b/web/source/settings/views/user/export-import/import.tsx
index 34de5757c..ee9e106f5 100644
--- a/web/source/settings/views/user/export-import/import.tsx
+++ b/web/source/settings/views/user/export-import/import.tsx
@@ -68,6 +68,7 @@ export default function Import() {
+
>
}>