summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/api/swagger.yaml172
-rw-r--r--docs/user_guide/settings.md8
-rw-r--r--internal/api/client.go4
-rw-r--r--internal/api/client/exports/blocks.go76
-rw-r--r--internal/api/client/exports/exports.go54
-rw-r--r--internal/api/client/exports/exports_test.go275
-rw-r--r--internal/api/client/exports/followers.go76
-rw-r--r--internal/api/client/exports/following.go76
-rw-r--r--internal/api/client/exports/lists.go76
-rw-r--r--internal/api/client/exports/mutes.go76
-rw-r--r--internal/api/client/exports/stats.go77
-rw-r--r--internal/api/model/exportimport.go60
-rw-r--r--internal/api/util/mime.go1
-rw-r--r--internal/api/util/negotiate.go6
-rw-r--r--internal/api/util/response.go42
-rw-r--r--internal/db/bundb/list.go8
-rw-r--r--internal/db/bundb/relationship.go5
-rw-r--r--internal/db/bundb/relationship_mute.go5
-rw-r--r--internal/db/list.go3
-rw-r--r--internal/db/relationship.go6
-rw-r--r--internal/processing/account/export.go159
-rw-r--r--internal/trans/export.go10
-rw-r--r--internal/trans/exportminimal.go2
-rw-r--r--internal/typeutils/csv.go385
-rw-r--r--web/source/settings/lib/query/gts-api.ts28
-rw-r--r--web/source/settings/lib/query/user/export-import.ts138
-rw-r--r--web/source/settings/lib/types/account.ts10
-rw-r--r--web/source/settings/style.css33
-rw-r--r--web/source/settings/views/user/export-import/export.tsx173
-rw-r--r--web/source/settings/views/user/export-import/index.tsx57
-rw-r--r--web/source/settings/views/user/menu.tsx5
-rw-r--r--web/source/settings/views/user/router.tsx3
32 files changed, 2102 insertions, 7 deletions
diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml
index 07ff289d7..102a00fbd 100644
--- a/docs/api/swagger.yaml
+++ b/docs/api/swagger.yaml
@@ -333,6 +333,56 @@ definitions:
type: object
x-go-name: Account
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
+ accountExportStats:
+ description: |-
+ AccountExportStats models an account's stats
+ specifically for the purpose of informing about
+ export sizes at the /api/v1/exports/stats endpoint.
+ properties:
+ blocks_count:
+ description: Number of accounts blocked by this account.
+ example: 15
+ format: int64
+ type: integer
+ x-go-name: BlocksCount
+ followers_count:
+ description: Number of accounts following this account.
+ example: 50
+ format: int64
+ type: integer
+ x-go-name: FollowersCount
+ following_count:
+ description: Number of accounts followed by this account.
+ example: 50
+ format: int64
+ type: integer
+ x-go-name: FollowingCount
+ lists_count:
+ description: Number of lists created by this account.
+ example: 10
+ format: int64
+ type: integer
+ x-go-name: ListsCount
+ media_storage:
+ description: 'TODO: String representation of media storage size attributed to this account.'
+ example: 500MB
+ type: string
+ x-go-name: MediaStorage
+ mutes_count:
+ description: Number of accounts muted by this account.
+ example: 11
+ format: int64
+ type: integer
+ x-go-name: MutesCount
+ statuses_count:
+ description: Number of statuses created by this account.
+ example: 81986
+ format: int64
+ type: integer
+ x-go-name: StatusesCount
+ type: object
+ x-go-name: AccountExportStats
+ x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
accountRelationship:
properties:
blocked_by:
@@ -6364,6 +6414,128 @@ paths:
summary: Get an array of custom emojis available on the instance.
tags:
- custom_emojis
+ /api/v1/exports/blocks.csv:
+ get:
+ operationId: exportBlocks
+ produces:
+ - text/csv
+ responses:
+ "200":
+ description: CSV file of accounts that you block.
+ "401":
+ description: unauthorized
+ "406":
+ description: not acceptable
+ "500":
+ description: internal server error
+ security:
+ - OAuth2 Bearer:
+ - read:blocks
+ summary: Export a CSV file of accounts that you block.
+ tags:
+ - import-export
+ /api/v1/exports/followers.csv:
+ get:
+ operationId: exportFollowers
+ produces:
+ - text/csv
+ responses:
+ "200":
+ description: CSV file of accounts that follow you.
+ "401":
+ description: unauthorized
+ "406":
+ description: not acceptable
+ "500":
+ description: internal server error
+ security:
+ - OAuth2 Bearer:
+ - read:follows
+ summary: Export a CSV file of accounts that follow you.
+ tags:
+ - import-export
+ /api/v1/exports/following.csv:
+ get:
+ operationId: exportFollowing
+ produces:
+ - text/csv
+ responses:
+ "200":
+ description: CSV file of accounts that you follow.
+ "401":
+ description: unauthorized
+ "406":
+ description: not acceptable
+ "500":
+ description: internal server error
+ security:
+ - OAuth2 Bearer:
+ - read:follows
+ summary: Export a CSV file of accounts that you follow.
+ tags:
+ - import-export
+ /api/v1/exports/lists.csv:
+ get:
+ operationId: exportLists
+ produces:
+ - text/csv
+ responses:
+ "200":
+ description: CSV file of lists.
+ "401":
+ description: unauthorized
+ "406":
+ description: not acceptable
+ "500":
+ description: internal server error
+ security:
+ - OAuth2 Bearer:
+ - read:lists
+ summary: Export a CSV file of lists created by you.
+ tags:
+ - import-export
+ /api/v1/exports/mutes.csv:
+ get:
+ operationId: exportMutes
+ produces:
+ - text/csv
+ responses:
+ "200":
+ description: CSV file of accounts that you mute.
+ "401":
+ description: unauthorized
+ "406":
+ description: not acceptable
+ "500":
+ description: internal server error
+ security:
+ - OAuth2 Bearer:
+ - read:mutes
+ summary: Export a CSV file of accounts that you mute.
+ tags:
+ - import-export
+ /api/v1/exports/stats:
+ get:
+ operationId: exportStats
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: Export stats for the requesting account.
+ schema:
+ $ref: '#/definitions/accountExportStats'
+ "401":
+ description: unauthorized
+ "406":
+ description: not acceptable
+ "500":
+ description: internal server error
+ security:
+ - OAuth2 Bearer:
+ - read:account
+ summary: Returns informational stats on the number of items that can be exported for requesting account.
+ tags:
+ - import-export
/api/v1/favourites:
get:
description: |-
diff --git a/docs/user_guide/settings.md b/docs/user_guide/settings.md
index 52afb056f..0811381c0 100644
--- a/docs/user_guide/settings.md
+++ b/docs/user_guide/settings.md
@@ -204,3 +204,11 @@ For more information on the way GoToSocial manages passwords, please see the [Pa
In the migration section you can manage settings related to aliasing and/or migrating your account to or from another account.
Please see the [migration document](./migration.md) for more information on moving your account.
+
+## Export & Import
+
+In the export & import section, you can export data from your GoToSocial account, or import data into it (TODO).
+
+### Export
+
+To export your following, followers, lists, account blocks, or account mutes, you can use the button on this page. All exports will be served in Mastodon-compatible CSV format, so you can import them later into Mastodon or another GoToSocial instance, if you like.
diff --git a/internal/api/client.go b/internal/api/client.go
index 18ab9b50b..64f185430 100644
--- a/internal/api/client.go
+++ b/internal/api/client.go
@@ -28,6 +28,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/api/client/bookmarks"
"github.com/superseriousbusiness/gotosocial/internal/api/client/conversations"
"github.com/superseriousbusiness/gotosocial/internal/api/client/customemojis"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/exports"
"github.com/superseriousbusiness/gotosocial/internal/api/client/favourites"
"github.com/superseriousbusiness/gotosocial/internal/api/client/featuredtags"
filtersV1 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v1"
@@ -68,6 +69,7 @@ type Client struct {
bookmarks *bookmarks.Module // api/v1/bookmarks
conversations *conversations.Module // api/v1/conversations
customEmojis *customemojis.Module // api/v1/custom_emojis
+ exports *exports.Module // api/v1/exports
favourites *favourites.Module // api/v1/favourites
featuredTags *featuredtags.Module // api/v1/featured_tags
filtersV1 *filtersV1.Module // api/v1/filters
@@ -116,6 +118,7 @@ func (c *Client) Route(r *router.Router, m ...gin.HandlerFunc) {
c.bookmarks.Route(h)
c.conversations.Route(h)
c.customEmojis.Route(h)
+ c.exports.Route(h)
c.favourites.Route(h)
c.featuredTags.Route(h)
c.filtersV1.Route(h)
@@ -152,6 +155,7 @@ func NewClient(state *state.State, p *processing.Processor) *Client {
bookmarks: bookmarks.New(p),
conversations: conversations.New(p),
customEmojis: customemojis.New(p),
+ exports: exports.New(p),
favourites: favourites.New(p),
featuredTags: featuredtags.New(p),
filtersV1: filtersV1.New(p),
diff --git a/internal/api/client/exports/blocks.go b/internal/api/client/exports/blocks.go
new file mode 100644
index 000000000..c31e2b0b4
--- /dev/null
+++ b/internal/api/client/exports/blocks.go
@@ -0,0 +1,76 @@
+// 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 <http://www.gnu.org/licenses/>.
+
+package exports
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// ExportBlocksGETHandler swagger:operation GET /api/v1/exports/blocks.csv exportBlocks
+//
+// Export a CSV file of accounts that you block.
+//
+// ---
+// tags:
+// - import-export
+//
+// produces:
+// - text/csv
+//
+// security:
+// - OAuth2 Bearer:
+// - read:blocks
+//
+// responses:
+// '200':
+// name: accounts
+// description: CSV file of accounts that you block.
+// '401':
+// description: unauthorized
+// '406':
+// description: not acceptable
+// '500':
+// description: internal server error
+func (m *Module) ExportBlocksGETHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ if _, err := apiutil.NegotiateAccept(c, apiutil.CSVHeaders...); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ records, errWithCode := m.processor.Account().ExportBlocks(
+ c.Request.Context(),
+ authed.Account,
+ )
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ apiutil.EncodeCSVResponse(c.Writer, c.Request, http.StatusOK, records)
+}
diff --git a/internal/api/client/exports/exports.go b/internal/api/client/exports/exports.go
new file mode 100644
index 000000000..90a246a74
--- /dev/null
+++ b/internal/api/client/exports/exports.go
@@ -0,0 +1,54 @@
+// 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 <http://www.gnu.org/licenses/>.
+
+package exports
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/superseriousbusiness/gotosocial/internal/processing"
+)
+
+const (
+ BasePath = "/v1/exports"
+ StatsPath = BasePath + "/stats"
+ FollowingPath = BasePath + "/following.csv"
+ FollowersPath = BasePath + "/followers.csv"
+ ListsPath = BasePath + "/lists.csv"
+ BlocksPath = BasePath + "/blocks.csv"
+ MutesPath = BasePath + "/mutes.csv"
+)
+
+type Module struct {
+ processor *processing.Processor
+}
+
+func New(processor *processing.Processor) *Module {
+ return &Module{
+ processor: processor,
+ }
+}
+
+func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) {
+ attachHandler(http.MethodGet, StatsPath, m.ExportStatsGETHandler)
+ attachHandler(http.MethodGet, FollowingPath, m.ExportFollowingGETHandler)
+ attachHandler(http.MethodGet, FollowersPath, m.ExportFollowersGETHandler)
+ attachHandler(http.MethodGet, ListsPath, m.ExportListsGETHandler)
+ attachHandler(http.MethodGet, BlocksPath, m.ExportBlocksGETHandler)
+ attachHandler(http.MethodGet, MutesPath, m.ExportMutesGETHandler)
+}
diff --git a/internal/api/client/exports/exports_test.go b/internal/api/client/exports/exports_test.go
new file mode 100644
index 000000000..1943f2582
--- /dev/null
+++ b/internal/api/client/exports/exports_test.go
@@ -0,0 +1,275 @@
+// 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 <http://www.gnu.org/licenses/>.
+
+package exports_test
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/exports"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/state"
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type ExportsTestSuite struct {
+ // Suite interfaces
+ suite.Suite
+ state state.State
+
+ // standard suite models
+ testTokens map[string]*gtsmodel.Token
+ testClients map[string]*gtsmodel.Client
+ testApplications map[string]*gtsmodel.Application
+ testUsers map[string]*gtsmodel.User
+ testAccounts map[string]*gtsmodel.Account
+
+ // module being tested
+ exportsModule *exports.Module
+}
+
+func (suite *ExportsTestSuite) SetupSuite() {
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+}
+
+func (suite *ExportsTestSuite) SetupTest() {
+ suite.state.Caches.Init()
+ testrig.StartNoopWorkers(&suite.state)
+
+ testrig.InitTestConfig()
+ testrig.InitTestLog()
+
+ suite.state.DB = testrig.NewTestDB(&suite.state)
+ suite.state.Storage = testrig.NewInMemoryStorage()
+
+ testrig.StartTimelines(
+ &suite.state,
+ visibility.NewFilter(&suite.state),
+ typeutils.NewConverter(&suite.state),
+ )
+
+ testrig.StandardDBSetup(suite.state.DB, nil)
+ testrig.StandardStorageSetup(suite.state.Storage, "../../../../testrig/media")
+
+ mediaManager := testrig.NewTestMediaManager(&suite.state)
+
+ federator := testrig.NewTestFederator(
+ &suite.state,
+ testrig.NewTestTransportController(
+ &suite.state,
+ testrig.NewMockHTTPClient(nil, "../../../../testrig/media"),
+ ),
+ mediaManager,
+ )
+
+ processor := testrig.NewTestProcessor(
+ &suite.state,
+ federator,
+ testrig.NewEmailSender("../../../../web/template/", nil),
+ mediaManager,
+ )
+
+ suite.exportsModule = exports.New(processor)
+}
+
+func (suite *ExportsTestSuite) TriggerHandler(
+ handler gin.HandlerFunc,
+ path string,
+ contentType string,
+ application *gtsmodel.Application,
+ token *gtsmodel.Token,
+ user *gtsmodel.User,
+ account *gtsmodel.Account,
+) *httptest.ResponseRecorder {
+ // Set up request.
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+
+ // Authorize the request ctx as though it
+ // had passed through API auth handlers.
+ ctx.Set(oauth.SessionAuthorizedApplication, application)
+ ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(token))
+ ctx.Set(oauth.SessionAuthorizedUser, user)
+ ctx.Set(oauth.SessionAuthorizedAccount, account)
+
+ // Create test request.
+ target := "http://localhost:8080/api" + path
+ ctx.Request = httptest.NewRequest(http.MethodGet, target, nil)
+ ctx.Request.Header.Set("Accept", contentType)
+
+ // Trigger handler.
+ handler(ctx)
+
+ return recorder
+}
+
+func (suite *ExportsTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.state.DB)
+ testrig.StandardStorageTeardown(suite.state.Storage)
+ testrig.StopWorkers(&suite.state)
+}
+
+func (suite *ExportsTestSuite) TestExports() {
+ type testCase struct {
+ handler gin.HandlerFunc
+ path string
+ contentType string
+ application *gtsmodel.Application
+ token *gtsmodel.Token
+ user *gtsmodel.User
+ account *gtsmodel.Account
+ expect string
+ }
+
+ testCases := []testCase{
+ // Export Following
+ {
+ handler: suite.exportsModule.ExportFollowingGETHandler,
+ path: exports.FollowingPath,
+ contentType: apiutil.TextCSV,
+ application: suite.testApplications["application_1"],
+ token: suite.testTokens["local_account_1"],
+ user: suite.testUsers["local_account_1"],
+ account: suite.testAccounts["local_account_1"],
+ expect: `Account address,Show boosts
+admin@localhost:8080,true
+1happyturtle@localhost:8080,true
+`,
+ },
+ // Export Followers.
+ {
+ handler: suite.exportsModule.ExportFollowersGETHandler,
+ path: exports.FollowingPath,
+ contentType: apiutil.TextCSV,
+ application: suite.testApplications["application_1"],
+ token: suite.testTokens["local_account_1"],
+ user: suite.testUsers["local_account_1"],
+ account: suite.testAccounts["local_account_1"],
+ expect: `Account address
+1happyturtle@localhost:8080
+admin@localhost:8080
+`,
+ },
+ // Export Lists.
+ {
+ handler: suite.exportsModule.ExportListsGETHandler,
+ path: exports.ListsPath,
+ contentType: apiutil.TextCSV,
+ application: suite.testApplications["application_1"],
+ token: suite.testTokens["local_account_1"],
+ user: suite.testUsers["local_account_1"],
+ account: suite.testAccounts["local_account_1"],
+ expect: `Cool Ass Posters From This Instance,admin@localhost:8080
+Cool Ass Posters From This Instance,1happyturtle@localhost:8080
+`,
+ },
+ // Export Mutes.
+ {
+ handler: suite.exportsModule.ExportMutesGETHandler,
+ path: exports.MutesPath,
+ contentType: apiutil.TextCSV,
+ application: suite.testApplications["application_1"],
+ token: suite.testTokens["local_account_1"],
+ user: suite.testUsers["local_account_1"],
+ account: suite.testAccounts["local_account_1"],
+ expect: `Account address,Hide notifications
+`,
+ },
+ // Export Blocks.
+ {
+ handler: suite.exportsModule.ExportBlocksGETHandler,
+ path: exports.BlocksPath,
+ contentType: apiutil.TextCSV,
+ application: suite.testApplications["application_1"],
+ token: suite.testTokens["local_account_2"],
+ user: suite.testUsers["local_account_2"],
+ account: suite.testAccounts["local_account_2"],
+ expect: `foss_satan@fossbros-anonymous.io
+`,
+ },
+ // Export Stats.
+ {
+ handler: suite.exportsModule.ExportStatsGETHandler,
+ path: exports.StatsPath,
+ contentType: apiutil.AppJSON,
+ application: suite.testApplications["application_1"],
+ token: suite.testTokens["local_account_1"],
+ user: suite.testUsers["local_account_1"],
+ account: suite.testAccounts["local_account_1"],
+ expect: `{
+ "media_storage": "",
+ "followers_count": 2,
+ "following_count": 2,
+ "statuses_count": 8,
+ "lists_count": 1,
+ "blocks_count": 0,
+ "mutes_count": 0
+}`,
+ },
+ }
+
+ for _, test := range testCases {
+ recorder := suite.TriggerHandler(
+ test.handler,
+ test.path,
+ test.contentType,
+ test.application,
+ test.token,
+ test.user,
+ test.account,
+ )
+
+ // Check response code.
+ suite.EqualValues(http.StatusOK, recorder.Code)
+
+ // Check response body.
+ b, err := io.ReadAll(recorder.Body)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // If json response, indent it nicely.
+ if recorder.Result().Header.Get("Content-Type") == "application/json" {
+ dst := &bytes.Buffer{}
+ if err := json.Indent(dst, b, "", " "); err != nil {
+ suite.FailNow(err.Error())
+ }
+ b = dst.Bytes()
+ }
+
+ suite.Equal(test.expect, string(b))
+ }
+}
+
+func TestExportsTestSuite(t *testing.T) {
+ suite.Run(t, new(ExportsTestSuite))
+}
diff --git a/internal/api/client/exports/followers.go b/internal/api/client/exports/followers.go
new file mode 100644
index 000000000..ceef94659
--- /dev/null
+++ b/internal/api/client/exports/followers.go
@@ -0,0 +1,76 @@
+// 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 <http://www.gnu.org/licenses/>.
+
+package exports
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// ExportFollowersGETHandler swagger:operation GET /api/v1/exports/followers.csv exportFollowers
+//
+// Export a CSV file of accounts that follow you.
+//
+// ---
+// tags:
+// - import-export
+//
+// produces:
+// - text/csv
+//
+// security:
+// - OAuth2 Bearer:
+// - read:follows
+//
+// responses:
+// '200':
+// name: accounts
+// description: CSV file of accounts that follow you.
+// '401':
+// description: unauthorized
+// '406':
+// description: not acceptable
+// '500':
+// description: internal server error
+func (m *Module) ExportFollowersGETHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ if _, err := apiutil.NegotiateAccept(c, apiutil.CSVHeaders...); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ records, errWithCode := m.processor.Account().ExportFollowers(
+ c.Request.Context(),
+ authed.Account,
+ )
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ apiutil.EncodeCSVResponse(c.Writer, c.Request, http.StatusOK, records)
+}
diff --git a/internal/api/client/exports/following.go b/internal/api/client/exports/following.go
new file mode 100644
index 000000000..e61cafc2a
--- /dev/null
+++ b/internal/api/client/exports/following.go
@@ -0,0 +1,76 @@
+// 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 <http://www.gnu.org/licenses/>.
+
+package exports
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// ExportFollowingGETHandler swagger:operation GET /api/v1/exports/following.csv exportFollowing
+//
+// Export a CSV file of accounts that you follow.
+//
+// ---
+// tags:
+// - import-export
+//
+// produces:
+// - text/csv
+//
+// security:
+// - OAuth2 Bearer:
+// - read:follows
+//
+// responses:
+// '200':
+// name: accounts
+// description: CSV file of accounts that you follow.
+// '401':
+// description: unauthorized
+// '406':
+// description: not acceptable
+// '500':
+// description: internal server error
+func (m *Module) ExportFollowingGETHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ if _, err := apiutil.NegotiateAccept(c, apiutil.CSVHeaders...); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ records, errWithCode := m.processor.Account().ExportFollowing(
+ c.Request.Context(),
+ authed.Account,
+ )
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ apiutil.EncodeCSVResponse(c.Writer, c.Request, http.StatusOK, records)
+}
diff --git a/internal/api/client/exports/lists.go b/internal/api/client/exports/lists.go
new file mode 100644
index 000000000..2debcc701
--- /dev/null
+++ b/internal/api/client/exports/lists.go
@@ -0,0 +1,76 @@
+// 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 <http://www.gnu.org/licenses/>.
+
+package exports
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// ExportListsGETHandler swagger:operation GET /api/v1/exports/lists.csv exportLists
+//
+// Export a CSV file of lists created by you.
+//
+// ---
+// tags:
+// - import-export
+//
+// produces:
+// - text/csv
+//
+// security:
+// - OAuth2 Bearer:
+// - read:lists
+//
+// responses:
+// '200':
+// name: accounts
+// description: CSV file of lists.
+// '401':
+// description: unauthorized
+// '406':
+// description: not acceptable
+// '500':
+// description: internal server error
+func (m *Module) ExportListsGETHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ if _, err := apiutil.NegotiateAccept(c, apiutil.CSVHeaders...); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ records, errWithCode := m.processor.Account().ExportLists(
+ c.Request.Context(),
+ authed.Account,
+ )
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ apiutil.EncodeCSVResponse(c.Writer, c.Request, http.StatusOK, records)
+}
diff --git a/internal/api/client/exports/mutes.go b/internal/api/client/exports/mutes.go
new file mode 100644
index 000000000..ab49b7719
--- /dev/null
+++ b/internal/api/client/exports/mutes.go
@@ -0,0 +1,76 @@
+// 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 <http://www.gnu.org/licenses/>.
+
+package exports
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// ExportMutesGETHandler swagger:operation GET /api/v1/exports/mutes.csv exportMutes
+//
+// Export a CSV file of accounts that you mute.
+//
+// ---
+// tags:
+// - import-export
+//
+// produces:
+// - text/csv
+//
+// security:
+// - OAuth2 Bearer:
+// - read:mutes
+//
+// responses:
+// '200':
+// name: accounts
+// description: CSV file of accounts that you mute.
+// '401':
+// description: unauthorized
+// '406':
+// description: not acceptable
+// '500':
+// description: internal server error
+func (m *Module) ExportMutesGETHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ if _, err := apiutil.NegotiateAccept(c, apiutil.CSVHeaders...); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ records, errWithCode := m.processor.Account().ExportMutes(
+ c.Request.Context(),
+ authed.Account,
+ )
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ apiutil.EncodeCSVResponse(c.Writer, c.Request, http.StatusOK, records)
+}
diff --git a/internal/api/client/exports/stats.go b/internal/api/client/exports/stats.go
new file mode 100644
index 000000000..9e3f1b600
--- /dev/null
+++ b/internal/api/client/exports/stats.go
@@ -0,0 +1,77 @@
+// 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 <http://www.gnu.org/licenses/>.
+
+package exports
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// ExportStatsGETHandler swagger:operation GET /api/v1/exports/stats exportStats
+//
+// Returns informational stats on the number of items that can be exported for requesting account.
+//
+// ---
+// tags:
+// - import-export
+//
+// produces:
+// - application/json
+//
+// security:
+// - OAuth2 Bearer:
+// - read:account
+//
+// responses:
+// '200':
+// description: Export stats for the requesting account.
+// schema:
+// "$ref": "#/definitions/accountExportStats"
+// '401':
+// description: unauthorized
+// '406':
+// description: not acceptable
+// '500':
+// description: internal server error
+func (m *Module) ExportStatsGETHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ exportStats, errWithCode := m.processor.Account().ExportStats(
+ c.Request.Context(),
+ authed.Account,
+ )
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ apiutil.JSON(c, http.StatusOK, exportStats)
+}
diff --git a/internal/api/model/exportimport.go b/internal/api/model/exportimport.go
new file mode 100644
index 000000000..d87ed8cd3
--- /dev/null
+++ b/internal/api/model/exportimport.go
@@ -0,0 +1,60 @@
+// 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 <http://www.gnu.org/licenses/>.
+
+package model
+
+// AccountExportStats models an account's stats
+// specifically for the purpose of informing about
+// export sizes at the /api/v1/exports/stats endpoint.
+//
+// swagger:model accountExportStats
+type AccountExportStats struct {
+ // TODO: String representation of media storage size attributed to this account.
+ //
+ // example: 500MB
+ MediaStorage string `json:"media_storage"`
+
+ // Number of accounts following this account.
+ //
+ // example: 50
+ FollowersCount int `json:"followers_count"`
+
+ // Number of accounts followed by this account.
+ //
+ // example: 50
+ FollowingCount int `json:"following_count"`
+
+ // Number of statuses created by this account.
+ //
+ // example: 81986
+ StatusesCount int `json:"statuses_count"`
+
+ // Number of lists created by this account.
+ //
+ // example: 10
+ ListsCount int `json:"lists_count"`
+
+ // Number of accounts blocked by this account.
+ //
+ // example: 15
+ BlocksCount int `json:"blocks_count"`
+
+ // Number of accounts muted by this account.
+ //
+ // example: 11
+ MutesCount int `json:"mutes_count"`
+}
diff --git a/internal/api/util/mime.go b/internal/api/util/mime.go
index 8dcbfc28c..4d8946e5d 100644
--- a/internal/api/util/mime.go
+++ b/internal/api/util/mime.go
@@ -35,6 +35,7 @@ const (
TextXML = `text/xml`
TextHTML = `text/html`
TextCSS = `text/css`
+ TextCSV = `text/csv`
)
// JSONContentType returns whether is application/json(;charset=utf-8)? content-type.
diff --git a/internal/api/util/negotiate.go b/internal/api/util/negotiate.go
index 5b4f54bc6..4910b7aef 100644
--- a/internal/api/util/negotiate.go
+++ b/internal/api/util/negotiate.go
@@ -88,6 +88,12 @@ var HostMetaHeaders = []string{
AppXML,
}
+// CSVHeaders just contains the text/csv
+// MIME type, used for import/export.
+var CSVHeaders = []string{
+ TextCSV,
+}
+
// NegotiateAccept takes the *gin.Context from an incoming request, and a
// slice of Offers, and performs content negotiation for the given request
// with the given content-type offers. It will return a string representation
diff --git a/internal/api/util/response.go b/internal/api/util/response.go
index afdc578aa..01f15ccfb 100644
--- a/internal/api/util/response.go
+++ b/internal/api/util/response.go
@@ -18,6 +18,7 @@
package util
import (
+ "encoding/csv"
"encoding/json"
"encoding/xml"
"io"
@@ -213,6 +214,47 @@ func EncodeXMLResponse(
putBuf(buf)
}
+// EncodeCSVResponse encodes 'records' as CSV HTTP response
+// to ResponseWriter with given status code, using CSV content-type.
+func EncodeCSVResponse(
+ rw http.ResponseWriter,
+ r *http.Request,
+ statusCode int,
+ records [][]string,
+) {
+ // Acquire buffer.
+ buf := getBuf()
+
+ // Wrap buffer in CSV writer.
+ csvWriter := csv.NewWriter(buf)
+
+ // Write all the records to the buffer.
+ if err := csvWriter.WriteAll(records); err == nil {
+ // Respond with the now-known
+ // size byte slice within buf.
+ WriteResponseBytes(rw, r,
+ statusCode,
+ TextCSV,
+ buf.B,
+ )
+ } else {
+ // This will always be an csv error, we
+ // can't really add any more useful context.
+ log.Error(r.Context(), err)
+
+ // Any error returned here is unrecoverable,
+ // set Internal Server Error JSON response.
+ WriteResponseBytes(rw, r,
+ http.StatusInternalServerError,
+ AppJSON,
+ StatusInternalServerErrorJSON,
+ )
+ }
+
+ // Release.
+ putBuf(buf)
+}
+
// writeResponseUnknownLength handles reading data of unknown legnth
// efficiently into memory, and passing on to WriteResponseBytes().
func writeResponseUnknownLength(
diff --git a/internal/db/bundb/list.go b/internal/db/bundb/list.go
index b8391ff6d..937257ef0 100644
--- a/internal/db/bundb/list.go
+++ b/internal/db/bundb/list.go
@@ -106,6 +106,14 @@ func (l *listDB) GetListsForAccountID(ctx context.Context, accountID string) ([]
return l.GetListsByIDs(ctx, listIDs)
}
+func (l *listDB) CountListsForAccountID(ctx context.Context, accountID string) (int, error) {
+ return l.db.
+ NewSelect().
+ Table("lists").
+ Where("? = ?", bun.Ident("account_id"), accountID).
+ Count(ctx)
+}
+
func (l *listDB) PopulateList(ctx context.Context, list *gtsmodel.List) error {
var (
err error
diff --git a/internal/db/bundb/relationship.go b/internal/db/bundb/relationship.go
index e3a4a2c0b..69b91f161 100644
--- a/internal/db/bundb/relationship.go
+++ b/internal/db/bundb/relationship.go
@@ -178,6 +178,11 @@ func (r *relationshipDB) GetAccountBlocks(ctx context.Context, accountID string,
return r.GetBlocksByIDs(ctx, blockIDs)
}
+func (r *relationshipDB) CountAccountBlocks(ctx context.Context, accountID string) (int, error) {
+ blockIDs, err := r.GetAccountBlockIDs(ctx, accountID, nil)
+ return len(blockIDs), err
+}
+
func (r *relationshipDB) GetAccountFollowIDs(ctx context.Context, accountID string, page *paging.Page) ([]string, error) {
return loadPagedIDs(&r.state.Caches.DB.FollowIDs, ">"+accountID, page, func() ([]string, error) {
var followIDs []string
diff --git a/internal/db/bundb/relationship_mute.go b/internal/db/bundb/relationship_mute.go
index 94c51050d..a84aad546 100644
--- a/internal/db/bundb/relationship_mute.go
+++ b/internal/db/bundb/relationship_mute.go
@@ -77,6 +77,11 @@ func (r *relationshipDB) GetMute(
)
}
+func (r *relationshipDB) CountAccountMutes(ctx context.Context, accountID string) (int, error) {
+ muteIDs, err := r.getAccountMuteIDs(ctx, accountID, nil)
+ return len(muteIDs), err
+}
+
func (r *relationshipDB) getMutesByIDs(ctx context.Context, ids []string) ([]*gtsmodel.UserMute, error) {
// Load all mutes IDs via cache loader callbacks.
mutes, err := r.state.Caches.DB.UserMute.LoadIDs("ID",
diff --git a/internal/db/list.go b/internal/db/list.go
index 16a0207de..a57f0ed23 100644
--- a/internal/db/list.go
+++ b/internal/db/list.go
@@ -33,6 +33,9 @@ type List interface {
// GetListsForAccountID gets all lists owned by the given accountID.
GetListsForAccountID(ctx context.Context, accountID string) ([]*gtsmodel.List, error)
+ // CountListsForAccountID counts the number of lists owned by the given accountID.
+ CountListsForAccountID(ctx context.Context, accountID string) (int, error)
+
// PopulateList ensures that the list's struct fields are populated.
PopulateList(ctx context.Context, list *gtsmodel.List) error
diff --git a/internal/db/relationship.go b/internal/db/relationship.go
index 5e0650fb7..ddc09d67b 100644
--- a/internal/db/relationship.go
+++ b/internal/db/relationship.go
@@ -179,6 +179,9 @@ type Relationship interface {
// GetAccountBlockIDs is like GetAccountBlocks, but returns just IDs.
GetAccountBlockIDs(ctx context.Context, accountID string, page *paging.Page) ([]string, error)
+ // CountAccountBlocks counts the number of blocks owned by the given account.
+ CountAccountBlocks(ctx context.Context, accountID string) (int, error)
+
// GetNote gets a private note from a source account on a target account, if it exists.
GetNote(ctx context.Context, sourceAccountID string, targetAccountID string) (*gtsmodel.AccountNote, error)
@@ -197,6 +200,9 @@ type Relationship interface {
// GetMute returns the mute from account1 targeting account2, if it exists, or an error if it doesn't.
GetMute(ctx context.Context, account1 string, account2 string) (*gtsmodel.UserMute, error)
+ // CountAccountMutes counts the number of mutes owned by the given account.
+ CountAccountMutes(ctx context.Context, accountID string) (int, error)
+
// PutMute attempts to insert or update the given account mute in the database.
PutMute(ctx context.Context, mute *gtsmodel.UserMute) error
diff --git a/internal/processing/account/export.go b/internal/processing/account/export.go
new file mode 100644
index 000000000..9954ea225
--- /dev/null
+++ b/internal/processing/account/export.go
@@ -0,0 +1,159 @@
+// 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 <http://www.gnu.org/licenses/>.
+
+package account
+
+import (
+ "context"
+ "errors"
+
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+// ExportStats returns the requester's export stats,
+// ie., the counts of items that can be exported.
+func (p *Processor) ExportStats(
+ ctx context.Context,
+ requester *gtsmodel.Account,
+) (*apimodel.AccountExportStats, gtserror.WithCode) {
+ exportStats, err := p.converter.AccountToExportStats(ctx, requester)
+ if err != nil {
+ err = gtserror.Newf("db error getting export stats: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ return exportStats, nil
+}
+
+// ExportFollowing returns a CSV file of
+// accounts that the requester follows.
+func (p *Processor) ExportFollowing(
+ ctx context.Context,
+ requester *gtsmodel.Account,
+) ([][]string, gtserror.WithCode) {
+ // Fetch accounts followed by requester,
+ // using a nil page to get everything.
+ following, err := p.state.DB.GetAccountFollows(ctx, requester.ID, nil)
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ err = gtserror.Newf("db error getting follows: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ // Convert accounts to CSV-compatible
+ // records, with appropriate column headers.
+ records, err := p.converter.FollowingToCSV(ctx, following)
+ if err != nil {
+ err = gtserror.Newf("error converting follows to records: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ return records, nil
+}
+
+// ExportFollowers returns a CSV file of
+// accounts that follow the requester.
+func (p *Processor) ExportFollowers(
+ ctx context.Context,
+ requester *gtsmodel.Account,
+) ([][]string, gtserror.WithCode) {
+ // Fetch accounts following requester,
+ // using a nil page to get everything.
+ followers, err := p.state.DB.GetAccountFollowers(ctx, requester.ID, nil)
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ err = gtserror.Newf("db error getting followers: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ // Convert accounts to CSV-compatible
+ // records, with appropriate column headers.
+ records, err := p.converter.FollowersToCSV(ctx, followers)
+ if err != nil {
+ err = gtserror.Newf("error converting followers to records: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ return records, nil
+}
+
+// ExportLists returns a CSV file of
+// lists created by the requester.
+func (p *Processor) ExportLists(
+ ctx context.Context,
+ requester *gtsmodel.Account,
+) ([][]string, gtserror.WithCode) {
+ lists, err := p.state.DB.GetListsForAccountID(ctx, requester.ID)
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ err = gtserror.Newf("db error getting lists: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ // Convert lists to CSV-compatible records.
+ records, err := p.converter.ListsToCSV(ctx, lists)
+ if err != nil {
+ err = gtserror.Newf("error converting lists to records: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ return records, nil
+}
+
+// ExportBlocks returns a CSV file of
+// account blocks created by the requester.
+func (p *Processor) ExportBlocks(
+ ctx context.Context,
+ requester *gtsmodel.Account,
+) ([][]string, gtserror.WithCode) {
+ blocks, err := p.state.DB.GetAccountBlocks(ctx, requester.ID, nil)
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ err = gtserror.Newf("db error getting blocks: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ // Convert blocks to CSV-compatible records.
+ records, err := p.converter.BlocksToCSV(ctx, blocks)
+ if err != nil {
+ err = gtserror.Newf("error converting blocks to records: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ return records, nil
+}
+
+// ExportMutes returns a CSV file of
+// account mutes created by the requester.
+func (p *Processor) ExportMutes(
+ ctx context.Context,
+ requester *gtsmodel.Account,
+) ([][]string, gtserror.WithCode) {
+ mutes, err := p.state.DB.GetAccountMutes(ctx, requester.ID, nil)
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ err = gtserror.Newf("db error getting mutes: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ // Convert mutes to CSV-compatible records.
+ records, err := p.converter.MutesToCSV(ctx, mutes)
+ if err != nil {
+ err = gtserror.Newf("error converting mutes to records: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ return records, nil
+}
diff --git a/internal/trans/export.go b/internal/trans/export.go
index 95ef0e2a8..f242c9b94 100644
--- a/internal/trans/export.go
+++ b/internal/trans/export.go
@@ -102,7 +102,7 @@ func (e *exporter) exportDomainBlocks(ctx context.Context, file *os.File) ([]*tr
return domainBlocks, nil
}
-func (e *exporter) exportFollows(ctx context.Context, accounts []*transmodel.Account, file *os.File) ([]*transmodel.Follow, error) {
+func (e *exporter) exportFollowing(ctx context.Context, accounts []*transmodel.Account, file *os.File) ([]*transmodel.Follow, error) {
followsUnique := make(map[string]*transmodel.Follow)
// for each account we want to export both where it's following and where it's followed
@@ -111,12 +111,12 @@ func (e *exporter) exportFollows(ctx context.Context, accounts []*transmodel.Acc
whereFollowing := []db.Where{{Key: "account_id", Value: a.ID}}
following := []*transmodel.Follow{}
if err := e.db.GetWhere(ctx, whereFollowing, &following); err != nil {
- return nil, fmt.Errorf("exportFollows: error selecting follows owned by account %s: %s", a.ID, err)
+ return nil, fmt.Errorf("exportFollowing: error selecting follows owned by account %s: %s", a.ID, err)
}
for _, follow := range following {
follow.Type = transmodel.TransFollow
if err := e.simpleEncode(ctx, file, follow, follow.ID); err != nil {
- return nil, fmt.Errorf("exportFollows: error encoding follow owned by account %s: %s", a.ID, err)
+ return nil, fmt.Errorf("exportFollowing: error encoding follow owned by account %s: %s", a.ID, err)
}
followsUnique[follow.ID] = follow
}
@@ -125,12 +125,12 @@ func (e *exporter) exportFollows(ctx context.Context, accounts []*transmodel.Acc
whereFollowed := []db.Where{{Key: "target_account_id", Value: a.ID}}
followed := []*transmodel.Follow{}
if err := e.db.GetWhere(ctx, whereFollowed, &followed); err != nil {
- return nil, fmt.Errorf("exportFollows: error selecting follows targeting account %s: %s", a.ID, err)
+ return nil, fmt.Errorf("exportFollowing: error selecting follows targeting account %s: %s", a.ID, err)
}
for _, follow := range followed {
follow.Type = transmodel.TransFollow
if err := e.simpleEncode(ctx, file, follow, follow.ID); err != nil {
- return nil, fmt.Errorf("exportFollows: error encoding follow targeting account %s: %s", a.ID, err)
+ return nil, fmt.Errorf("exportFollowing: error encoding follow targeting account %s: %s", a.ID, err)
}
followsUnique[follow.ID] = follow
}
diff --git a/internal/trans/exportminimal.go b/internal/trans/exportminimal.go
index dc0e3368e..fea7d5af7 100644
--- a/internal/trans/exportminimal.go
+++ b/internal/trans/exportminimal.go
@@ -69,7 +69,7 @@ func (e *exporter) ExportMinimal(ctx context.Context, path string) error {
}
// export all follows that relate to local accounts
- follows, err := e.exportFollows(ctx, localAccounts, file)
+ follows, err := e.exportFollowing(ctx, localAccounts, file)
if err != nil {
return fmt.Errorf("ExportMinimal: error exporting follows: %s", err)
}
diff --git a/internal/typeutils/csv.go b/internal/typeutils/csv.go
new file mode 100644
index 000000000..2ef56cb0c
--- /dev/null
+++ b/internal/typeutils/csv.go
@@ -0,0 +1,385 @@
+// 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 <http://www.gnu.org/licenses/>.
+
+package typeutils
+
+import (
+ "context"
+ "strconv"
+
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/gtscontext"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+func (c *Converter) AccountToExportStats(
+ ctx context.Context,
+ a *gtsmodel.Account,
+) (*apimodel.AccountExportStats, error) {
+ // Ensure account stats populated.
+ if a.Stats == nil {
+ if err := c.state.DB.PopulateAccountStats(ctx, a); err != nil {
+ return nil, gtserror.Newf(
+ "error getting stats for account %s: %w",
+ a.ID, err,
+ )
+ }
+ }
+
+ listsCount, err := c.state.DB.CountListsForAccountID(ctx, a.ID)
+ if err != nil {
+ return nil, gtserror.Newf(
+ "error counting lists for account %s: %w",
+ a.ID, err,
+ )
+ }
+
+ blockingCount, err := c.state.DB.CountAccountBlocks(ctx, a.ID)
+ if err != nil {
+ return nil, gtserror.Newf(
+ "error counting lists for account %s: %w",
+ a.ID, err,
+ )
+ }
+
+ mutingCount, err := c.state.DB.CountAccountMutes(ctx, a.ID)
+ if err != nil {
+ return nil, gtserror.Newf(
+ "error counting lists for account %s: %w",
+ a.ID, err,
+ )
+ }
+
+ return &apimodel.AccountExportStats{
+ FollowersCount: *a.Stats.FollowersCount,
+ FollowingCount: *a.Stats.FollowingCount,
+ StatusesCount: *a.Stats.StatusesCount,
+ ListsCount: listsCount,
+ BlocksCount: blockingCount,
+ MutesCount: mutingCount,
+ }, nil
+}
+
+// FollowingToCSV converts a slice of follows into
+// a slice of CSV-compatible Following records.
+func (c *Converter) FollowingToCSV(
+ ctx context.Context,
+ following []*gtsmodel.Follow,
+) ([][]string, error) {
+ // Records should be length of
+ // input + 1 so we can add headers.
+ records := make([][]string, 1, len(following)+1)
+
+ // Add headers at the
+ // top of records.
+ records[0] = []string{
+ "Account address",
+ "Show boosts",
+ }
+
+ // We need to know our own domain for this.
+ // Try account domain, fall back to host.
+ thisDomain := config.GetAccountDomain()
+ if thisDomain == "" {
+ thisDomain = config.GetHost()
+ }
+
+ // For each item, add a record.
+ for _, follow := range following {
+ if follow.TargetAccount == nil {
+ // Retrieve target account.
+ var err error
+ follow.TargetAccount, err = c.state.DB.GetAccountByID(
+ // Barebones is fine here.
+ gtscontext.SetBarebones(ctx),
+ follow.TargetAccountID,
+ )
+ if err != nil {
+ return nil, gtserror.Newf(
+ "db error getting target account for follow %s: %w",
+ follow.ID, err,
+ )
+ }
+ }
+
+ domain := follow.TargetAccount.Domain
+ if domain == "" {
+ // Local account,
+ // use our domain.
+ domain = thisDomain
+ }
+
+ records = append(records, []string{
+ // Account address: eg., someone@example.org
+ // -- NOTE: without the leading '@'!
+ follow.TargetAccount.Username + "@" + domain,
+ // Show boosts: eg., true
+ strconv.FormatBool(*follow.ShowReblogs),
+ })
+ }
+
+ return records, nil
+}
+
+// FollowersToCSV converts a slice of follows into
+// a slice of CSV-compatible Followers records.
+func (c *Converter) FollowersToCSV(
+ ctx context.Context,
+ followers []*gtsmodel.Follow,
+) ([][]string, error) {
+ // Records should be length of
+ // input + 1 so we can add headers.
+ records := make([][]string, 1, len(followers)+1)
+
+ // Add header at the
+ // top of records.
+ records[0] = []string{
+ "Account address",
+ }
+
+ // We need to know our own domain for this.
+ // Try account domain, fall back to host.
+ thisDomain := config.GetAccountDomain()
+ if thisDomain == "" {
+ thisDomain = config.GetHost()
+ }
+
+ // For each item, add a record.
+ for _, follow := range followers {
+ if follow.Account == nil {
+ // Retrieve account.
+ var err error
+ follow.Account, err = c.state.DB.GetAccountByID(
+ // Barebones is fine here.
+ gtscontext.SetBarebones(ctx),
+ follow.AccountID,
+ )
+ if err != nil {
+ return nil, gtserror.Newf(
+ "db error getting account for follow %s: %w",
+ follow.ID, err,
+ )
+ }
+ }
+
+ domain := follow.Account.Domain
+ if domain == "" {
+ // Local account,
+ // use our domain.
+ domain = thisDomain
+ }
+
+ records = append(records, []string{
+ // Account address: eg., someone@example.org
+ // -- NOTE: without the leading '@'!
+ follow.Account.Username + "@" + domain,
+ })
+ }
+
+ return records, nil
+}
+
+// FollowersToCSV converts a slice of follows into
+// a slice of CSV-compatible Followers records.
+func (c *Converter) ListsToCSV(
+ ctx context.Context,
+ lists []*gtsmodel.List,
+) ([][]string, error) {
+ // We need to know our own domain for this.
+ // Try account domain, fall back to host.
+ thisDomain := config.GetAccountDomain()
+ if thisDomain == "" {
+ thisDomain = config.GetHost()
+ }
+
+ // NOTE: Mastodon-compatible lists
+ // CSV doesn't use column headers.
+ records := make([][]string, 0)
+
+ // For each item, add a record.
+ for _, list := range lists {
+ for _, entry := range list.ListEntries {
+ if entry.Follow == nil {
+ // Retrieve follow.
+ var err error
+ entry.Follow, err = c.state.DB.GetFollowByID(
+ ctx,
+ entry.FollowID,
+ )
+ if err != nil {
+ return nil, gtserror.Newf(
+ "db error getting follow for list entry %s: %w",
+ entry.ID, err,
+ )
+ }
+ }
+
+ if entry.Follow.TargetAccount == nil {
+ // Retrieve account.
+ var err error
+ entry.Follow.TargetAccount, err = c.state.DB.GetAccountByID(
+ // Barebones is fine here.
+ gtscontext.SetBarebones(ctx),
+ entry.Follow.TargetAccountID,
+ )
+ if err != nil {
+ return nil, gtserror.Newf(
+ "db error getting target account for list entry %s: %w",
+ entry.ID, err,
+ )
+ }
+ }
+
+ var (
+ username = entry.Follow.TargetAccount.Username
+ domain = entry.Follow.TargetAccount.Domain
+ )
+
+ if domain == "" {
+ // Local account,
+ // use our domain.
+ domain = thisDomain
+ }
+
+ records = append(records, []string{
+ // List title: eg., Very cool list
+ list.Title,
+ // Account address: eg., someone@example.org
+ // -- NOTE: without the leading '@'!
+ username + "@" + domain,
+ })
+ }
+
+ }
+
+ return records, nil
+}
+
+// BlocksToCSV converts a slice of blocks into
+// a slice of CSV-compatible blocks records.
+func (c *Converter) BlocksToCSV(
+ ctx context.Context,
+ blocks []*gtsmodel.Block,
+) ([][]string, error) {
+ // We need to know our own domain for this.
+ // Try account domain, fall back to host.
+ thisDomain := config.GetAccountDomain()
+ if thisDomain == "" {
+ thisDomain = config.GetHost()
+ }
+
+ // NOTE: Mastodon-compatible blocks
+ // CSV doesn't use column headers.
+ records := make([][]string, 0, len(blocks))
+
+ // For each item, add a record.
+ for _, block := range blocks {
+ if block.TargetAccount == nil {
+ // Retrieve target account.
+ var err error
+ block.TargetAccount, err = c.state.DB.GetAccountByID(
+ // Barebones is fine here.
+ gtscontext.SetBarebones(ctx),
+ block.TargetAccountID,
+ )
+ if err != nil {
+ return nil, gtserror.Newf(
+ "db error getting target account for block %s: %w",
+ block.ID, err,
+ )
+ }
+ }
+
+ domain := block.TargetAccount.Domain
+ if domain == "" {
+ // Local account,
+ // use our domain.
+ domain = thisDomain
+ }
+
+ records = append(records, []string{
+ // Account address: eg., someone@example.org
+ // -- NOTE: without the leading '@'!
+ block.TargetAccount.Username + "@" + domain,
+ })
+ }
+
+ return records, nil
+}
+
+// MutesToCSV converts a slice of mutes into
+// a slice of CSV-compatible mute records.
+func (c *Converter) MutesToCSV(
+ ctx context.Context,
+ mutes []*gtsmodel.UserMute,
+) ([][]string, error) {
+ // Records should be length of
+ // input + 1 so we can add headers.
+ records := make([][]string, 1, len(mutes)+1)
+
+ // Add headers at the
+ // top of records.
+ records[0] = []string{
+ "Account address",
+ "Hide notifications",
+ }
+
+ // We need to know our own domain for this.
+ // Try account domain, fall back to host.
+ thisDomain := config.GetAccountDomain()
+ if thisDomain == "" {
+ thisDomain = config.GetHost()
+ }
+
+ // For each item, add a record.
+ for _, mute := range mutes {
+ if mute.TargetAccount == nil {
+ // Retrieve target account.
+ var err error
+ mute.TargetAccount, err = c.state.DB.GetAccountByID(
+ // Barebones is fine here.
+ gtscontext.SetBarebones(ctx),
+ mute.TargetAccountID,
+ )
+ if err != nil {
+ return nil, gtserror.Newf(
+ "db error getting target account for mute %s: %w",
+ mute.ID, err,
+ )
+ }
+ }
+
+ domain := mute.TargetAccount.Domain
+ if domain == "" {
+ // Local account,
+ // use our domain.
+ domain = thisDomain
+ }
+
+ records = append(records, []string{
+ // Account address: eg., someone@example.org
+ // -- NOTE: without the leading '@'!
+ mute.TargetAccount.Username + "@" + domain,
+ // Hide notifications: eg., true
+ strconv.FormatBool(*mute.Notifications),
+ })
+ }
+
+ return records, nil
+}
diff --git a/web/source/settings/lib/query/gts-api.ts b/web/source/settings/lib/query/gts-api.ts
index d6741df3a..1c715e284 100644
--- a/web/source/settings/lib/query/gts-api.ts
+++ b/web/source/settings/lib/query/gts-api.ts
@@ -48,6 +48,11 @@ export interface GTSFetchArgs extends FetchArgs {
* as FormData before submission.
*/
asForm?: boolean;
+ /**
+ * If set, then Accept header will
+ * be set to the provided contentType.
+ */
+ acceptContentType?: string;
}
/**
@@ -77,6 +82,10 @@ const gtsBaseQuery: BaseQueryFn<
// Derive baseUrl dynamically.
let baseUrl: string | undefined;
+ // Assume Accept value of
+ // "application/json" by default.
+ let accept = "application/json";
+
// Check if simple string baseUrl provided
// as args, or if more complex args provided.
if (typeof args === "string") {
@@ -101,11 +110,16 @@ const gtsBaseQuery: BaseQueryFn<
});
}
+ if (args.acceptContentType !== undefined) {
+ accept = args.acceptContentType;
+ }
+
// Delete any of our extended arguments
// to avoid confusing fetchBaseQuery.
delete args.baseUrl;
delete args.discardEmpty;
delete args.asForm;
+ delete args.acceptContentType;
}
if (!baseUrl) {
@@ -124,9 +138,21 @@ const gtsBaseQuery: BaseQueryFn<
if (token != undefined) {
headers.set('Authorization', token);
}
- headers.set("Accept", "application/json");
+
+ headers.set("Accept", accept);
return headers;
},
+ responseHandler: (response) => {
+ // Return just text if caller has
+ // set a custom accept content-type.
+ if (accept !== "application/json") {
+ return response.text();
+ }
+
+ // Else return good old
+ // fashioned JSON baby!
+ return response.json();
+ },
})(args, api, extraOptions);
};
diff --git a/web/source/settings/lib/query/user/export-import.ts b/web/source/settings/lib/query/user/export-import.ts
new file mode 100644
index 000000000..56c48e364
--- /dev/null
+++ b/web/source/settings/lib/query/user/export-import.ts
@@ -0,0 +1,138 @@
+/*
+ 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 <http://www.gnu.org/licenses/>.
+*/
+
+import fileDownload from "js-file-download";
+
+import { gtsApi } from "../gts-api";
+import { FetchBaseQueryError } from "@reduxjs/toolkit/query";
+import { AccountExportStats } from "../../types/account";
+
+const extended = gtsApi.injectEndpoints({
+ endpoints: (build) => ({
+ exportStats: build.query<AccountExportStats, void>({
+ query: () => ({
+ url: `/api/v1/exports/stats`
+ })
+ }),
+
+ exportFollowing: build.mutation<string | null, void>({
+ async queryFn(_arg, _api, _extraOpts, fetchWithBQ) {
+ const csvRes = await fetchWithBQ({
+ url: `/api/v1/exports/following.csv`,
+ acceptContentType: "text/csv",
+ });
+ if (csvRes.error) {
+ return { error: csvRes.error as FetchBaseQueryError };
+ }
+
+ if (csvRes.meta?.response?.status !== 200) {
+ return { error: csvRes.data };
+ }
+
+ fileDownload(csvRes.data, "following.csv", "text/csv");
+ return { data: null };
+ }
+ }),
+
+ exportFollowers: build.mutation<string | null, void>({
+ async queryFn(_arg, _api, _extraOpts, fetchWithBQ) {
+ const csvRes = await fetchWithBQ({
+ url: `/api/v1/exports/followers.csv`,
+ acceptContentType: "text/csv",
+ });
+ if (csvRes.error) {
+ return { error: csvRes.error as FetchBaseQueryError };
+ }
+
+ if (csvRes.meta?.response?.status !== 200) {
+ return { error: csvRes.data };
+ }
+
+ fileDownload(csvRes.data, "followers.csv", "text/csv");
+ return { data: null };
+ }
+ }),
+
+ exportLists: build.mutation<string | null, void>({
+ async queryFn(_arg, _api, _extraOpts, fetchWithBQ) {
+ const csvRes = await fetchWithBQ({
+ url: `/api/v1/exports/lists.csv`,
+ acceptContentType: "text/csv",
+ });
+ if (csvRes.error) {
+ return { error: csvRes.error as FetchBaseQueryError };
+ }
+
+ if (csvRes.meta?.response?.status !== 200) {
+ return { error: csvRes.data };
+ }
+
+ fileDownload(csvRes.data, "lists.csv", "text/csv");
+ return { data: null };
+ }
+ }),
+
+ exportBlocks: build.mutation<string | null, void>({
+ async queryFn(_arg, _api, _extraOpts, fetchWithBQ) {
+ const csvRes = await fetchWithBQ({
+ url: `/api/v1/exports/blocks.csv`,
+ acceptContentType: "text/csv",
+ });
+ if (csvRes.error) {
+ return { error: csvRes.error as FetchBaseQueryError };
+ }
+
+ if (csvRes.meta?.response?.status !== 200) {
+ return { error: csvRes.data };
+ }
+
+ fileDownload(csvRes.data, "blocks.csv", "text/csv");
+ return { data: null };
+ }
+ }),
+
+ exportMutes: build.mutation<string | null, void>({
+ async queryFn(_arg, _api, _extraOpts, fetchWithBQ) {
+ const csvRes = await fetchWithBQ({
+ url: `/api/v1/exports/mutes.csv`,
+ acceptContentType: "text/csv",
+ });
+ if (csvRes.error) {
+ return { error: csvRes.error as FetchBaseQueryError };
+ }
+
+ if (csvRes.meta?.response?.status !== 200) {
+ return { error: csvRes.data };
+ }
+
+ fileDownload(csvRes.data, "mutes.csv", "text/csv");
+ return { data: null };
+ }
+ }),
+ })
+});
+
+export const {
+ useExportStatsQuery,
+ useExportFollowingMutation,
+ useExportFollowersMutation,
+ useExportListsMutation,
+ useExportBlocksMutation,
+ useExportMutesMutation,
+} = extended;
diff --git a/web/source/settings/lib/types/account.ts b/web/source/settings/lib/types/account.ts
index 590b2c98e..6b8d2bc4d 100644
--- a/web/source/settings/lib/types/account.ts
+++ b/web/source/settings/lib/types/account.ts
@@ -110,3 +110,13 @@ export interface ActionAccountParams {
action: "suspend";
reason: string;
}
+
+export interface AccountExportStats {
+ media_storage: string;
+ followers_count: number;
+ following_count: number;
+ statuses_count: number;
+ lists_count: number;
+ blocks_count: number;
+ mutes_count: number;
+}
diff --git a/web/source/settings/style.css b/web/source/settings/style.css
index 9650f7466..3d1545634 100644
--- a/web/source/settings/style.css
+++ b/web/source/settings/style.css
@@ -1464,6 +1464,39 @@ button.tab-button {
}
}
+.export-data {
+ .export-buttons-wrapper {
+ display: grid;
+ max-width: fit-content;
+ gap: 0.5rem;
+
+ .stats-and-button {
+ display: grid;
+ grid-template-columns: 13rem 1fr;
+ align-items: center;
+ gap: 0.25rem;
+
+ .mutation-button {
+ width: 100%;
+ overflow-x: hidden;
+
+ button {
+ font-size: 1rem;
+ width: 100%;
+ }
+ }
+ }
+
+ @media screen and (max-width: 35rem) {
+ gap: 1rem;
+
+ .stats-and-button {
+ grid-template-columns: auto;
+ }
+ }
+ }
+}
+
@media screen and (orientation: portrait) {
.reports .report .byline {
grid-template-columns: 1fr;
diff --git a/web/source/settings/views/user/export-import/export.tsx b/web/source/settings/views/user/export-import/export.tsx
new file mode 100644
index 000000000..70bda60f2
--- /dev/null
+++ b/web/source/settings/views/user/export-import/export.tsx
@@ -0,0 +1,173 @@
+/*
+ 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 <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+import {
+ useExportFollowingMutation,
+ useExportFollowersMutation,
+ useExportListsMutation,
+ useExportBlocksMutation,
+ useExportMutesMutation,
+} from "../../../lib/query/user/export-import";
+import MutationButton from "../../../components/form/mutation-button";
+import useFormSubmit from "../../../lib/form/submit";
+import { useValue } from "../../../lib/form";
+import { AccountExportStats } from "../../../lib/types/account";
+
+export default function Export({ exportStats }: { exportStats: AccountExportStats }) {
+ const [exportFollowing, exportFollowingResult] = useFormSubmit(
+ // Use a dummy value.
+ { type: useValue("exportFollowing", "exportFollowing") },
+ // Mutation we're wrapping.
+ useExportFollowingMutation(),
+ // Form never changes but
+ // we want to always trigger.
+ { changedOnly: false },
+ );
+
+ const [exportFollowers, exportFollowersResult] = useFormSubmit(
+ // Use a dummy value.
+ { type: useValue("exportFollowers", "exportFollowers") },
+ // Mutation we're wrapping.
+ useExportFollowersMutation(),
+ // Form never changes but
+ // we want to always trigger.
+ { changedOnly: false },
+ );
+
+ const [exportLists, exportListsResult] = useFormSubmit(
+ // Use a dummy value.
+ { type: useValue("exportLists", "exportLists") },
+ // Mutation we're wrapping.
+ useExportListsMutation(),
+ // Form never changes but
+ // we want to always trigger.
+ { changedOnly: false },
+ );
+
+
+ const [exportBlocks, exportBlocksResult] = useFormSubmit(
+ // Use a dummy value.
+ { type: useValue("exportBlocks", "exportBlocks") },
+ // Mutation we're wrapping.
+ useExportBlocksMutation(),
+ // Form never changes but
+ // we want to always trigger.
+ { changedOnly: false },
+ );
+
+ const [exportMutes, exportMutesResult] = useFormSubmit(
+ // Use a dummy value.
+ { type: useValue("exportMutes", "exportMutes") },
+ // Mutation we're wrapping.
+ useExportMutesMutation(),
+ // Form never changes but
+ // we want to always trigger.
+ { changedOnly: false },
+ );
+
+ return (
+ <form className="export-data">
+ <div className="form-section-docs">
+ <h3>Export Data</h3>
+ <a
+ href="https://docs.gotosocial.org/en/latest/user_guide/export-import#export"
+ target="_blank"
+ className="docslink"
+ rel="noreferrer"
+ >
+ Learn more about this section (opens in a new tab)
+ </a>
+ </div>
+
+ <div className="export-buttons-wrapper">
+ <div className="stats-and-button">
+ <span className="text-cutoff">
+ Following {exportStats.following_count} account{ exportStats.following_count !== 1 && "s" }
+ </span>
+ <MutationButton
+ className="text-cutoff"
+ label="Download following.csv"
+ type="button"
+ onClick={() => exportFollowing()}
+ result={exportFollowingResult}
+ showError={true}
+ disabled={exportStats.following_count === 0}
+ />
+ </div>
+ <div className="stats-and-button">
+ <span className="text-cutoff">
+ Followed by {exportStats.followers_count} account{ exportStats.followers_count !== 1 && "s" }
+ </span>
+ <MutationButton
+ className="text-cutoff"
+ label="Download followers.csv"
+ type="button"
+ onClick={() => exportFollowers()}
+ result={exportFollowersResult}
+ showError={true}
+ disabled={exportStats.followers_count === 0}
+ />
+ </div>
+ <div className="stats-and-button">
+ <span className="text-cutoff">
+ Created {exportStats.lists_count} list{ exportStats.lists_count !== 1 && "s" }
+ </span>
+ <MutationButton
+ className="text-cutoff"
+ label="Download lists.csv"
+ type="button"
+ onClick={() => exportLists()}
+ result={exportListsResult}
+ showError={true}
+ disabled={exportStats.lists_count === 0}
+ />
+ </div>
+ <div className="stats-and-button">
+ <span className="text-cutoff">
+ Blocking {exportStats.blocks_count} account{ exportStats.blocks_count !== 1 && "s" }
+ </span>
+ <MutationButton
+ className="text-cutoff"
+ label="Download blocks.csv"
+ type="button"
+ onClick={() => exportBlocks()}
+ result={exportBlocksResult}
+ showError={true}
+ disabled={exportStats.blocks_count === 0}
+ />
+ </div>
+ <div className="stats-and-button">
+ <span className="text-cutoff">
+ Muting {exportStats.mutes_count} account{ exportStats.mutes_count !== 1 && "s" }
+ </span>
+ <MutationButton
+ className="text-cutoff"
+ label="Download mutes.csv"
+ type="button"
+ onClick={() => exportMutes()}
+ result={exportMutesResult}
+ showError={true}
+ disabled={exportStats.mutes_count === 0}
+ />
+ </div>
+ </div>
+ </form>
+ );
+}
diff --git a/web/source/settings/views/user/export-import/index.tsx b/web/source/settings/views/user/export-import/index.tsx
new file mode 100644
index 000000000..2e3533318
--- /dev/null
+++ b/web/source/settings/views/user/export-import/index.tsx
@@ -0,0 +1,57 @@
+/*
+ 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 <http://www.gnu.org/licenses/>.
+*/
+
+import React from "react";
+import Export from "./export";
+import Loading from "../../../components/loading";
+import { Error } from "../../../components/error";
+import { useExportStatsQuery } from "../../../lib/query/user/export-import";
+
+export default function ExportImport() {
+ const {
+ data: exportStats,
+ isLoading,
+ isFetching,
+ isError,
+ error,
+ } = useExportStatsQuery();
+
+ if (isLoading || isFetching) {
+ return <Loading />;
+ }
+
+ if (isError) {
+ return <Error error={error} />;
+ }
+
+ if (exportStats === undefined) {
+ throw "undefined account export stats";
+ }
+
+ return (
+ <>
+ <h1>Export & Import</h1>
+ <p>
+ On this page you can export data from your GoToSocial account, or import data into
+ your GoToSocial account. All exports and imports use Mastodon-compatible CSV files.
+ </p>
+ <Export exportStats={exportStats} />
+ </>
+ );
+}
diff --git a/web/source/settings/views/user/menu.tsx b/web/source/settings/views/user/menu.tsx
index 3d90bfe21..a0526d652 100644
--- a/web/source/settings/views/user/menu.tsx
+++ b/web/source/settings/views/user/menu.tsx
@@ -53,6 +53,11 @@ export default function UserMenu() {
itemUrl="migration"
icon="fa-exchange"
/>
+ <MenuItem
+ name="Export & Import"
+ itemUrl="export-import"
+ icon="fa-floppy-o"
+ />
</MenuItem>
);
}
diff --git a/web/source/settings/views/user/router.tsx b/web/source/settings/views/user/router.tsx
index 5b74aee68..7b995b3b7 100644
--- a/web/source/settings/views/user/router.tsx
+++ b/web/source/settings/views/user/router.tsx
@@ -25,12 +25,14 @@ import UserProfile from "./profile";
import UserMigration from "./migration";
import PostSettings from "./posts";
import EmailPassword from "./emailpassword";
+import ExportImport from "./export-import";
/**
* - /settings/user/profile
* - /settings/user/posts
* - /settings/user/emailpassword
* - /settings/user/migration
+ * - /settings/user/export-import
*/
export default function UserRouter() {
const baseUrl = useBaseUrl();
@@ -46,6 +48,7 @@ export default function UserRouter() {
<Route path="/posts" component={PostSettings} />
<Route path="/emailpassword" component={EmailPassword} />
<Route path="/migration" component={UserMigration} />
+ <Route path="/export-import" component={ExportImport} />
<Route><Redirect to="/profile" /></Route>
</Switch>
</ErrorBoundary>