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 @@ "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 . + +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 . + +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 . + +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 . + +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 . + +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 . + +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 . + +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 . + +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 . + +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 @@ 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 @@ 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(mute *gtsmodel.UserMute) error { ) } +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 . + +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 . + +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 . +*/ + +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({ + query: () => ({ + url: `/api/v1/exports/stats` + }) + }), + + exportFollowing: build.mutation({ + 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({ + 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({ + 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({ + 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({ + 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 . +*/ + +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 ( +
+ + +
+
+ + Following {exportStats.following_count} account{ exportStats.following_count !== 1 && "s" } + + exportFollowing()} + result={exportFollowingResult} + showError={true} + disabled={exportStats.following_count === 0} + /> +
+
+ + Followed by {exportStats.followers_count} account{ exportStats.followers_count !== 1 && "s" } + + exportFollowers()} + result={exportFollowersResult} + showError={true} + disabled={exportStats.followers_count === 0} + /> +
+
+ + Created {exportStats.lists_count} list{ exportStats.lists_count !== 1 && "s" } + + exportLists()} + result={exportListsResult} + showError={true} + disabled={exportStats.lists_count === 0} + /> +
+
+ + Blocking {exportStats.blocks_count} account{ exportStats.blocks_count !== 1 && "s" } + + exportBlocks()} + result={exportBlocksResult} + showError={true} + disabled={exportStats.blocks_count === 0} + /> +
+
+ + Muting {exportStats.mutes_count} account{ exportStats.mutes_count !== 1 && "s" } + + exportMutes()} + result={exportMutesResult} + showError={true} + disabled={exportStats.mutes_count === 0} + /> +
+
+
+ ); +} 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 . +*/ + +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 ; + } + + if (isError) { + return ; + } + + if (exportStats === undefined) { + throw "undefined account export stats"; + } + + return ( + <> +

Export & Import

+

+ 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. +

+ + + ); +} 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" /> + ); } 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() { +