diff options
| author | 2024-07-31 16:03:34 +0200 | |
|---|---|---|
| committer | 2024-07-31 15:03:34 +0100 | |
| commit | 38f041cea1ba0cd3492f351353a29aa5b73e2731 (patch) | |
| tree | bdd055d5cf7d9c06523c694cb4abe86d220960d0 | |
| parent | [feature] Object store custom URL (S3) (#3046) (diff) | |
| download | gotosocial-38f041cea1ba0cd3492f351353a29aa5b73e2731.tar.xz | |
[feature] Allow users to export data via the settings panel (#3140)
* [feature] Allow users to export data via the settings panel
* rename/move some stuff
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>  | 
