From 7b5917d6ae48f83c92f92d7277960cfa6ae8ec56 Mon Sep 17 00:00:00 2001
From: tobi <31960611+tsmethurst@users.noreply.github.com>
Date: Fri, 2 Aug 2024 13:41:46 +0200
Subject: [feature] Allow import of following and blocks via CSV (#3150)
* [feature] Import follows + blocks via settings panel
* test import follows
---
internal/api/auth/token_test.go | 8 +-
internal/api/client.go | 4 +
internal/api/client/accounts/accountdelete_test.go | 6 +-
internal/api/client/accounts/accountupdate_test.go | 6 +-
internal/api/client/admin/emojicreate_test.go | 8 +-
internal/api/client/admin/emojiupdate_test.go | 22 +--
internal/api/client/import/import.go | 195 +++++++++++++++++++
internal/api/client/import/import_test.go | 210 +++++++++++++++++++++
internal/api/client/instance/instancepatch_test.go | 9 +-
internal/api/client/lists/listaccountsadd_test.go | 2 +-
internal/api/client/media/mediacreate_test.go | 8 +-
internal/api/client/media/mediaupdate_test.go | 4 +-
internal/api/client/polls/polls_vote_test.go | 2 +-
internal/api/model/exportimport.go | 22 +++
14 files changed, 471 insertions(+), 35 deletions(-)
create mode 100644 internal/api/client/import/import.go
create mode 100644 internal/api/client/import/import_test.go
(limited to 'internal/api')
diff --git a/internal/api/auth/token_test.go b/internal/api/auth/token_test.go
index c97fce3b9..1c53b5b2e 100644
--- a/internal/api/auth/token_test.go
+++ b/internal/api/auth/token_test.go
@@ -57,7 +57,7 @@ func (suite *TokenTestSuite) TestRetrieveClientCredentialsOK() {
testClient := suite.testClients["local_account_1"]
requestBody, w, err := testrig.CreateMultipartFormData(
- "", "",
+ nil,
map[string][]string{
"grant_type": {"client_credentials"},
"client_id": {testClient.ID},
@@ -103,7 +103,7 @@ func (suite *TokenTestSuite) TestRetrieveAuthorizationCodeOK() {
testUserAuthorizationToken := suite.testTokens["local_account_1_user_authorization_token"]
requestBody, w, err := testrig.CreateMultipartFormData(
- "", "",
+ nil,
map[string][]string{
"grant_type": {"authorization_code"},
"client_id": {testClient.ID},
@@ -148,7 +148,7 @@ func (suite *TokenTestSuite) TestRetrieveAuthorizationCodeNoCode() {
testClient := suite.testClients["local_account_1"]
requestBody, w, err := testrig.CreateMultipartFormData(
- "", "",
+ nil,
map[string][]string{
"grant_type": {"authorization_code"},
"client_id": {testClient.ID},
@@ -180,7 +180,7 @@ func (suite *TokenTestSuite) TestRetrieveAuthorizationCodeWrongGrantType() {
testClient := suite.testClients["local_account_1"]
requestBody, w, err := testrig.CreateMultipartFormData(
- "", "",
+ nil,
map[string][]string{
"grant_type": {"client_credentials"},
"client_id": {testClient.ID},
diff --git a/internal/api/client.go b/internal/api/client.go
index 64f185430..65d4f29d5 100644
--- a/internal/api/client.go
+++ b/internal/api/client.go
@@ -35,6 +35,7 @@ import (
filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2"
"github.com/superseriousbusiness/gotosocial/internal/api/client/followedtags"
"github.com/superseriousbusiness/gotosocial/internal/api/client/followrequests"
+ importdata "github.com/superseriousbusiness/gotosocial/internal/api/client/import"
"github.com/superseriousbusiness/gotosocial/internal/api/client/instance"
"github.com/superseriousbusiness/gotosocial/internal/api/client/interactionpolicies"
"github.com/superseriousbusiness/gotosocial/internal/api/client/lists"
@@ -76,6 +77,7 @@ type Client struct {
filtersV2 *filtersV2.Module // api/v2/filters
followRequests *followrequests.Module // api/v1/follow_requests
followedTags *followedtags.Module // api/v1/followed_tags
+ importData *importdata.Module // api/v1/import
instance *instance.Module // api/v1/instance
interactionPolicies *interactionpolicies.Module // api/v1/interaction_policies
lists *lists.Module // api/v1/lists
@@ -125,6 +127,7 @@ func (c *Client) Route(r *router.Router, m ...gin.HandlerFunc) {
c.filtersV2.Route(h)
c.followRequests.Route(h)
c.followedTags.Route(h)
+ c.importData.Route(h)
c.instance.Route(h)
c.interactionPolicies.Route(h)
c.lists.Route(h)
@@ -162,6 +165,7 @@ func NewClient(state *state.State, p *processing.Processor) *Client {
filtersV2: filtersV2.New(p),
followRequests: followrequests.New(p),
followedTags: followedtags.New(p),
+ importData: importdata.New(p),
instance: instance.New(p),
interactionPolicies: interactionpolicies.New(p),
lists: lists.New(p),
diff --git a/internal/api/client/accounts/accountdelete_test.go b/internal/api/client/accounts/accountdelete_test.go
index 2f5a25b4b..66a5fa097 100644
--- a/internal/api/client/accounts/accountdelete_test.go
+++ b/internal/api/client/accounts/accountdelete_test.go
@@ -35,7 +35,7 @@ func (suite *AccountDeleteTestSuite) TestAccountDeletePOSTHandler() {
// set up the request
// we're deleting zork
requestBody, w, err := testrig.CreateMultipartFormData(
- "", "",
+ nil,
map[string][]string{
"password": {"password"},
})
@@ -57,7 +57,7 @@ func (suite *AccountDeleteTestSuite) TestAccountDeletePOSTHandlerWrongPassword()
// set up the request
// we're deleting zork
requestBody, w, err := testrig.CreateMultipartFormData(
- "", "",
+ nil,
map[string][]string{
"password": {"aaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
})
@@ -79,7 +79,7 @@ func (suite *AccountDeleteTestSuite) TestAccountDeletePOSTHandlerNoPassword() {
// set up the request
// we're deleting zork
requestBody, w, err := testrig.CreateMultipartFormData(
- "", "",
+ nil,
map[string][]string{})
if err != nil {
panic(err)
diff --git a/internal/api/client/accounts/accountupdate_test.go b/internal/api/client/accounts/accountupdate_test.go
index 09996e998..d0def500c 100644
--- a/internal/api/client/accounts/accountupdate_test.go
+++ b/internal/api/client/accounts/accountupdate_test.go
@@ -51,7 +51,7 @@ func (suite *AccountUpdateTestSuite) updateAccountFromForm(data map[string][]str
}
func (suite *AccountUpdateTestSuite) updateAccountFromFormData(data map[string][]string, expectedHTTPStatus int, expectedBody string) (*apimodel.Account, error) {
- requestBody, w, err := testrig.CreateMultipartFormData("", "", data)
+ requestBody, w, err := testrig.CreateMultipartFormData(nil, data)
if err != nil {
suite.FailNow(err.Error())
}
@@ -59,8 +59,8 @@ func (suite *AccountUpdateTestSuite) updateAccountFromFormData(data map[string][
return suite.updateAccount(requestBody.Bytes(), w.FormDataContentType(), expectedHTTPStatus, expectedBody)
}
-func (suite *AccountUpdateTestSuite) updateAccountFromFormDataWithFile(fieldName string, fileName string, data map[string][]string, expectedHTTPStatus int, expectedBody string) (*apimodel.Account, error) {
- requestBody, w, err := testrig.CreateMultipartFormData(fieldName, fileName, data)
+func (suite *AccountUpdateTestSuite) updateAccountFromFormDataWithFile(fieldName string, filePath string, data map[string][]string, expectedHTTPStatus int, expectedBody string) (*apimodel.Account, error) {
+ requestBody, w, err := testrig.CreateMultipartFormData(testrig.FileToDataF(fieldName, filePath), data)
if err != nil {
suite.FailNow(err.Error())
}
diff --git a/internal/api/client/admin/emojicreate_test.go b/internal/api/client/admin/emojicreate_test.go
index a687fb0af..9e985459b 100644
--- a/internal/api/client/admin/emojicreate_test.go
+++ b/internal/api/client/admin/emojicreate_test.go
@@ -38,7 +38,7 @@ type EmojiCreateTestSuite struct {
func (suite *EmojiCreateTestSuite) TestEmojiCreateNewCategory() {
// set up the request
requestBody, w, err := testrig.CreateMultipartFormData(
- "image", "../../../../testrig/media/rainbow-original.png",
+ testrig.FileToDataF("image", "../../../../testrig/media/rainbow-original.png"),
map[string][]string{
"shortcode": {"new_emoji"},
"category": {"Test Emojis"}, // this category doesn't exist yet
@@ -111,7 +111,7 @@ func (suite *EmojiCreateTestSuite) TestEmojiCreateNewCategory() {
func (suite *EmojiCreateTestSuite) TestEmojiCreateExistingCategory() {
// set up the request
requestBody, w, err := testrig.CreateMultipartFormData(
- "image", "../../../../testrig/media/rainbow-original.png",
+ testrig.FileToDataF("image", "../../../../testrig/media/rainbow-original.png"),
map[string][]string{
"shortcode": {"new_emoji"},
"category": {"cute stuff"}, // this category already exists
@@ -184,7 +184,7 @@ func (suite *EmojiCreateTestSuite) TestEmojiCreateExistingCategory() {
func (suite *EmojiCreateTestSuite) TestEmojiCreateNoCategory() {
// set up the request
requestBody, w, err := testrig.CreateMultipartFormData(
- "image", "../../../../testrig/media/rainbow-original.png",
+ testrig.FileToDataF("image", "../../../../testrig/media/rainbow-original.png"),
map[string][]string{
"shortcode": {"new_emoji"},
"category": {""},
@@ -257,7 +257,7 @@ func (suite *EmojiCreateTestSuite) TestEmojiCreateNoCategory() {
func (suite *EmojiCreateTestSuite) TestEmojiCreateAlreadyExists() {
// set up the request -- use a shortcode that already exists for an emoji in the database
requestBody, w, err := testrig.CreateMultipartFormData(
- "image", "../../../../testrig/media/rainbow-original.png",
+ testrig.FileToDataF("image", "../../../../testrig/media/rainbow-original.png"),
map[string][]string{
"shortcode": {"rainbow"},
})
diff --git a/internal/api/client/admin/emojiupdate_test.go b/internal/api/client/admin/emojiupdate_test.go
index 073e3cec0..5df43d7ae 100644
--- a/internal/api/client/admin/emojiupdate_test.go
+++ b/internal/api/client/admin/emojiupdate_test.go
@@ -44,7 +44,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateNewCategory() {
// set up the request
requestBody, w, err := testrig.CreateMultipartFormData(
- "", "",
+ nil,
map[string][]string{
"category": {"New Category"}, // this category doesn't exist yet
"type": {"modify"},
@@ -121,7 +121,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateSwitchCategory() {
// set up the request
requestBody, w, err := testrig.CreateMultipartFormData(
- "", "",
+ nil,
map[string][]string{
"type": {"modify"},
"category": {"cute stuff"},
@@ -198,7 +198,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyRemoteToLocal() {
// set up the request
requestBody, w, err := testrig.CreateMultipartFormData(
- "", "",
+ nil,
map[string][]string{
"type": {"copy"},
"category": {"emojis i stole"},
@@ -276,7 +276,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateDisableEmoji() {
// set up the request
requestBody, w, err := testrig.CreateMultipartFormData(
- "", "",
+ nil,
map[string][]string{
"type": {"disable"},
})
@@ -317,7 +317,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateDisableLocalEmoji() {
// set up the request
requestBody, w, err := testrig.CreateMultipartFormData(
- "", "",
+ nil,
map[string][]string{
"type": {"disable"},
})
@@ -350,7 +350,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateModifyRemoteEmoji() {
// set up the request
requestBody, w, err := testrig.CreateMultipartFormData(
- "image", "../../../../testrig/media/kip-original.gif",
+ testrig.FileToDataF("image", "../../../../testrig/media/kip-original.gif"),
map[string][]string{
"type": {"modify"},
})
@@ -383,7 +383,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateModifyNoParams() {
// set up the request
requestBody, w, err := testrig.CreateMultipartFormData(
- "", "",
+ nil,
map[string][]string{
"type": {"modify"},
})
@@ -416,7 +416,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyLocalToLocal() {
// set up the request
requestBody, w, err := testrig.CreateMultipartFormData(
- "", "",
+ nil,
map[string][]string{
"type": {"copy"},
"shortcode": {"bottoms"},
@@ -450,7 +450,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyEmptyShortcode() {
// set up the request
requestBody, w, err := testrig.CreateMultipartFormData(
- "", "",
+ nil,
map[string][]string{
"type": {"copy"},
"shortcode": {""},
@@ -484,7 +484,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyNoShortcode() {
// set up the request
requestBody, w, err := testrig.CreateMultipartFormData(
- "", "",
+ nil,
map[string][]string{
"type": {"copy"},
})
@@ -517,7 +517,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyShortcodeAlreadyInUse() {
// set up the request
requestBody, w, err := testrig.CreateMultipartFormData(
- "", "",
+ nil,
map[string][]string{
"type": {"copy"},
"shortcode": {"rainbow"},
diff --git a/internal/api/client/import/import.go b/internal/api/client/import/import.go
new file mode 100644
index 000000000..6d85a6b23
--- /dev/null
+++ b/internal/api/client/import/import.go
@@ -0,0 +1,195 @@
+// 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
This is some html, which is allowed in short descriptions.
"}, }) diff --git a/internal/api/client/lists/listaccountsadd_test.go b/internal/api/client/lists/listaccountsadd_test.go index 492996882..7e44eeed3 100644 --- a/internal/api/client/lists/listaccountsadd_test.go +++ b/internal/api/client/lists/listaccountsadd_test.go @@ -60,7 +60,7 @@ func (suite *ListAccountsAddTestSuite) postListAccounts( requestPath := config.GetProtocol() + "://" + config.GetHost() + "/api/" + lists.BasePath + "/" + listID + "/accounts" // Prepare test body. - buf, w, err := testrig.CreateMultipartFormData("", "", map[string][]string{ + buf, w, err := testrig.CreateMultipartFormData(nil, map[string][]string{ "account_ids[]": accountIDs, }) diff --git a/internal/api/client/media/mediacreate_test.go b/internal/api/client/media/mediacreate_test.go index c256d18dc..4c2725681 100644 --- a/internal/api/client/media/mediacreate_test.go +++ b/internal/api/client/media/mediacreate_test.go @@ -149,7 +149,7 @@ func (suite *MediaCreateTestSuite) TestMediaCreateSuccessful() { } // create the request - buf, w, err := testrig.CreateMultipartFormData("file", "../../../../testrig/media/test-jpeg.jpg", map[string][]string{ + buf, w, err := testrig.CreateMultipartFormData(testrig.FileToDataF("file", "../../../../testrig/media/test-jpeg.jpg"), map[string][]string{ "description": {"this is a test image -- a cool background from somewhere"}, "focus": {"-0.5,0.5"}, }) @@ -234,7 +234,7 @@ func (suite *MediaCreateTestSuite) TestMediaCreateSuccessfulV2() { } // create the request - buf, w, err := testrig.CreateMultipartFormData("file", "../../../../testrig/media/test-jpeg.jpg", map[string][]string{ + buf, w, err := testrig.CreateMultipartFormData(testrig.FileToDataF("file", "../../../../testrig/media/test-jpeg.jpg"), map[string][]string{ "description": {"this is a test image -- a cool background from somewhere"}, "focus": {"-0.5,0.5"}, }) @@ -317,7 +317,7 @@ func (suite *MediaCreateTestSuite) TestMediaCreateLongDescription() { description := base64.RawStdEncoding.EncodeToString(descriptionBytes) // create the request - buf, w, err := testrig.CreateMultipartFormData("file", "../../../../testrig/media/test-jpeg.jpg", map[string][]string{ + buf, w, err := testrig.CreateMultipartFormData(testrig.FileToDataF("file", "../../../../testrig/media/test-jpeg.jpg"), map[string][]string{ "description": {description}, "focus": {"-0.5,0.5"}, }) @@ -358,7 +358,7 @@ func (suite *MediaCreateTestSuite) TestMediaCreateTooShortDescription() { ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) // create the request - buf, w, err := testrig.CreateMultipartFormData("file", "../../../../testrig/media/test-jpeg.jpg", map[string][]string{ + buf, w, err := testrig.CreateMultipartFormData(testrig.FileToDataF("file", "../../../../testrig/media/test-jpeg.jpg"), map[string][]string{ "description": {""}, // provide an empty description "focus": {"-0.5,0.5"}, }) diff --git a/internal/api/client/media/mediaupdate_test.go b/internal/api/client/media/mediaupdate_test.go index 43b2b6c51..c3a1fb340 100644 --- a/internal/api/client/media/mediaupdate_test.go +++ b/internal/api/client/media/mediaupdate_test.go @@ -140,7 +140,7 @@ func (suite *MediaUpdateTestSuite) TestUpdateImage() { ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) // create the request - buf, w, err := testrig.CreateMultipartFormData("", "", map[string][]string{ + buf, w, err := testrig.CreateMultipartFormData(nil, map[string][]string{ "id": {toUpdate.ID}, "description": {"new description!"}, "focus": {"-0.1,0.3"}, @@ -201,7 +201,7 @@ func (suite *MediaUpdateTestSuite) TestUpdateImageShortDescription() { ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) // create the request - buf, w, err := testrig.CreateMultipartFormData("", "", map[string][]string{ + buf, w, err := testrig.CreateMultipartFormData(nil, map[string][]string{ "id": {toUpdate.ID}, "description": {"new description!"}, "focus": {"-0.1,0.3"}, diff --git a/internal/api/client/polls/polls_vote_test.go b/internal/api/client/polls/polls_vote_test.go index 01bd941d3..54f98c192 100644 --- a/internal/api/client/polls/polls_vote_test.go +++ b/internal/api/client/polls/polls_vote_test.go @@ -107,7 +107,7 @@ func (suite *PollCreateTestSuite) formVoteInPoll( choicesStrs = append(choicesStrs, strconv.Itoa(choice)) } - body, w, err := testrig.CreateMultipartFormData("", "", map[string][]string{ + body, w, err := testrig.CreateMultipartFormData(nil, map[string][]string{ "choices[]": choicesStrs, }) diff --git a/internal/api/model/exportimport.go b/internal/api/model/exportimport.go index d87ed8cd3..88ea5489d 100644 --- a/internal/api/model/exportimport.go +++ b/internal/api/model/exportimport.go @@ -17,6 +17,8 @@ package model +import "mime/multipart" + // AccountExportStats models an account's stats // specifically for the purpose of informing about // export sizes at the /api/v1/exports/stats endpoint. @@ -58,3 +60,23 @@ type AccountExportStats struct { // example: 11 MutesCount int `json:"mutes_count"` } + +// AttachmentRequest models media attachment creation parameters. +// +// swagger: ignore +type ImportRequest struct { + // The CSV data to upload. + Data *multipart.FileHeader `form:"data" binding:"required"` + // Type of entries contained in the data file. + // + // - `following` - accounts to follow. + // - `lists` - lists of accounts. + // - `blocks` - accounts to block. + // - `mutes` - accounts to mute. + // - `bookmarks` - statuses to bookmark. + Type string `form:"type" binding:"required"` + // Mode to use when creating entries from the data file: + // - `merge` to merge entries in file with existing entries. + // - `overwrite` to replace existing entries with entries in file. + Mode string `form:"mode"` +} -- cgit v1.2.3