mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2025-03-03 17:21:08 +00:00
[feature] Implement CSV import for mutes (#3696)
* Implement CSV import for mutes * update swagger.yaml * update documentation * add ImportTestSuite.TestImportMutes * fix comment typo
This commit is contained in:
parent
d73acc70d5
commit
0118e03cda
7 changed files with 349 additions and 1 deletions
|
@ -8403,7 +8403,7 @@ paths:
|
||||||
type: file
|
type: file
|
||||||
- description: |-
|
- description: |-
|
||||||
Type of entries contained in the data file:
|
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
|
in: formData
|
||||||
name: type
|
name: type
|
||||||
required: true
|
required: true
|
||||||
|
|
|
@ -266,3 +266,6 @@ Both merge and overwrite operations are idempotent, which basically means that d
|
||||||
|
|
||||||
!!! info
|
!!! 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.
|
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.
|
||||||
|
|
|
@ -25,6 +25,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||||
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"
|
||||||
|
@ -38,6 +39,7 @@ const (
|
||||||
var types = []string{
|
var types = []string{
|
||||||
"following",
|
"following",
|
||||||
"blocks",
|
"blocks",
|
||||||
|
"mutes",
|
||||||
}
|
}
|
||||||
|
|
||||||
var modes = []string{
|
var modes = []string{
|
||||||
|
@ -93,6 +95,8 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H
|
||||||
//
|
//
|
||||||
// - `following` - accounts to follow.
|
// - `following` - accounts to follow.
|
||||||
// - `blocks` - accounts to block.
|
// - `blocks` - accounts to block.
|
||||||
|
// - `mutes` - accounts to mute.
|
||||||
|
//
|
||||||
// type: string
|
// type: string
|
||||||
// required: true
|
// required: true
|
||||||
// -
|
// -
|
||||||
|
|
|
@ -26,6 +26,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/suite"
|
"github.com/stretchr/testify/suite"
|
||||||
|
|
||||||
importdata "github.com/superseriousbusiness/gotosocial/internal/api/client/import"
|
importdata "github.com/superseriousbusiness/gotosocial/internal/api/client/import"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"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) {
|
func TestImportTestSuite(t *testing.T) {
|
||||||
suite.Run(t, new(ImportTestSuite))
|
suite.Run(t, new(ImportTestSuite))
|
||||||
}
|
}
|
||||||
|
|
|
@ -55,6 +55,14 @@ func (p *Processor) ImportData(
|
||||||
overwrite,
|
overwrite,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
case "mutes":
|
||||||
|
return p.importMutes(
|
||||||
|
ctx,
|
||||||
|
requester,
|
||||||
|
data,
|
||||||
|
overwrite,
|
||||||
|
)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
const text = "import type not yet supported"
|
const text = "import type not yet supported"
|
||||||
return gtserror.NewErrorUnprocessableEntity(errors.New(text), text)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -553,3 +553,96 @@ func (c *Converter) CSVToBlocks(
|
||||||
|
|
||||||
return blocks, nil
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -68,6 +68,7 @@ export default function Import() {
|
||||||
<option value="">- Select import type -</option>
|
<option value="">- Select import type -</option>
|
||||||
<option value="following">Following list</option>
|
<option value="following">Following list</option>
|
||||||
<option value="blocks">Blocked accounts list</option>
|
<option value="blocks">Blocked accounts list</option>
|
||||||
|
<option value="mutes">Muted accounts list</option>
|
||||||
</>
|
</>
|
||||||
}>
|
}>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
Loading…
Reference in a new issue