Implement CSV import for mutes

This commit is contained in:
Xavier Vello 2025-01-27 12:04:03 +01:00
parent 0a99901c65
commit 1be57118ec
4 changed files with 253 additions and 0 deletions

View file

@ -25,6 +25,7 @@
"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"
@ -39,6 +40,7 @@
var types = []string{
"following",
"blocks",
"mutes",
}
var modes = []string{
@ -94,6 +96,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
// -

View file

@ -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 blocks 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
}
}
}
}

View file

@ -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, &gtsmodel.UserMute{
TargetAccount: &gtsmodel.Account{
Username: username,
Domain: domain,
},
Notifications: hideNotifications,
})
}
return mutes, nil
}

View file

@ -68,6 +68,7 @@ export default function Import() {
<option value="">- Select import type -</option>
<option value="following">Following list</option>
<option value="blocks">Blocked accounts list</option>
<option value="mutes">Muted accounts list</option>
</>
}>
</Select>