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() { + }>