diff options
| author | 2024-07-31 16:03:34 +0200 | |
|---|---|---|
| committer | 2024-07-31 15:03:34 +0100 | |
| commit | 38f041cea1ba0cd3492f351353a29aa5b73e2731 (patch) | |
| tree | bdd055d5cf7d9c06523c694cb4abe86d220960d0 /internal | |
| 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
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/api/client.go | 4 | ||||
| -rw-r--r-- | internal/api/client/exports/blocks.go | 76 | ||||
| -rw-r--r-- | internal/api/client/exports/exports.go | 54 | ||||
| -rw-r--r-- | internal/api/client/exports/exports_test.go | 275 | ||||
| -rw-r--r-- | internal/api/client/exports/followers.go | 76 | ||||
| -rw-r--r-- | internal/api/client/exports/following.go | 76 | ||||
| -rw-r--r-- | internal/api/client/exports/lists.go | 76 | ||||
| -rw-r--r-- | internal/api/client/exports/mutes.go | 76 | ||||
| -rw-r--r-- | internal/api/client/exports/stats.go | 77 | ||||
| -rw-r--r-- | internal/api/model/exportimport.go | 60 | ||||
| -rw-r--r-- | internal/api/util/mime.go | 1 | ||||
| -rw-r--r-- | internal/api/util/negotiate.go | 6 | ||||
| -rw-r--r-- | internal/api/util/response.go | 42 | ||||
| -rw-r--r-- | internal/db/bundb/list.go | 8 | ||||
| -rw-r--r-- | internal/db/bundb/relationship.go | 5 | ||||
| -rw-r--r-- | internal/db/bundb/relationship_mute.go | 5 | ||||
| -rw-r--r-- | internal/db/list.go | 3 | ||||
| -rw-r--r-- | internal/db/relationship.go | 6 | ||||
| -rw-r--r-- | internal/processing/account/export.go | 159 | ||||
| -rw-r--r-- | internal/trans/export.go | 10 | ||||
| -rw-r--r-- | internal/trans/exportminimal.go | 2 | ||||
| -rw-r--r-- | internal/typeutils/csv.go | 385 | 
22 files changed, 1476 insertions, 6 deletions
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 +}  | 
