From 725a21b02721f92ed0420ed3f807ee921de77992 Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Wed, 1 May 2024 15:11:22 +0200 Subject: [PATCH] [feature] Page through accounts as moderator (#2881) * [feature] Page through accounts as moderator * aaaaa * use COLLATE "C" for Postgres to ensure same ordering as SQLite * fix typo, test paging up * don't show moderation / info for our instance acct --- docs/api/swagger.yaml | 28 +- internal/api/client/admin/accountsgetv1.go | 29 +- internal/api/client/admin/accountsgetv2.go | 29 +- .../api/client/admin/accountsgetv2_test.go | 546 ++++++++++++++++++ .../api/client/statuses/statushistory_test.go | 4 +- internal/db/bundb/account.go | 117 +++- internal/db/bundb/account_test.go | 74 +++ .../20240426122821_pageable_admin_accounts.go | 84 +++ internal/processing/admin/accounts.go | 8 +- web/source/package.json | 2 + .../settings/components/account-list.tsx | 82 --- .../settings/components/pageable-list.tsx | 113 ++++ .../reports => components}/username.tsx | 48 +- web/source/settings/index.tsx | 9 +- web/source/settings/lib/query/admin/index.ts | 13 +- web/source/settings/lib/query/gts-api.ts | 6 +- web/source/settings/lib/types/account.ts | 6 + web/source/settings/style.css | 109 ++-- .../moderation/accounts/detail/actions.tsx | 126 +++- .../accounts/detail/handlesignup.tsx | 114 ---- .../moderation/accounts/detail/index.tsx | 157 +++-- .../views/moderation/accounts/detail/util.tsx | 43 ++ .../views/moderation/accounts/index.tsx | 4 +- .../moderation/accounts/pending/index.tsx | 28 +- .../moderation/accounts/search/index.tsx | 80 ++- web/source/settings/views/moderation/menu.tsx | 8 +- .../views/moderation/reports/detail.tsx | 12 +- .../views/moderation/reports/overview.tsx | 4 +- .../settings/views/moderation/router.tsx | 10 +- web/source/yarn.lock | 12 + 30 files changed, 1473 insertions(+), 432 deletions(-) create mode 100644 internal/api/client/admin/accountsgetv2_test.go create mode 100644 internal/db/bundb/migrations/20240426122821_pageable_admin_accounts.go delete mode 100644 web/source/settings/components/account-list.tsx create mode 100644 web/source/settings/components/pageable-list.tsx rename web/source/settings/{views/moderation/reports => components}/username.tsx (55%) delete mode 100644 web/source/settings/views/moderation/accounts/detail/handlesignup.tsx create mode 100644 web/source/settings/views/moderation/accounts/detail/util.tsx diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index 465beb42b..dda090c52 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -3774,11 +3774,13 @@ paths: /api/v1/admin/accounts: get: description: |- + Returned accounts will be ordered alphabetically (a-z) by domain + username. + The next and previous queries can be parsed from the returned Link header. Example: ``` - ; rel="next", ; rel="prev" + ; rel="next", ; rel="prev" ```` operationId: adminAccountsGetV1 parameters: @@ -3847,19 +3849,15 @@ paths: in: query name: staff type: boolean - - description: All results returned will be older than the item with this ID. + - description: max_id in the form `[domain]/@[username]`. All results returned will be later in the alphabet than `[domain]/@[username]`. For example, if max_id = `example.org/@someone` then returned entries might contain `example.org/@someone_else`, `later.example.org/@someone`, etc. Local account IDs in this form use an empty string for the `[domain]` part, for example local account with username `someone` would be `/@someone`. in: query name: max_id type: string - - description: All results returned will be newer than the item with this ID. - in: query - name: since_id - type: string - - description: Returns results immediately newer than the item with this ID. + - description: min_id in the form `[domain]/@[username]`. All results returned will be earlier in the alphabet than `[domain]/@[username]`. For example, if min_id = `example.org/@someone` then returned entries might contain `example.org/@earlier_account`, `earlier.example.org/@someone`, etc. Local account IDs in this form use an empty string for the `[domain]` part, for example local account with username `someone` would be `/@someone`. in: query name: min_id type: string - - default: 100 + - default: 50 description: Maximum number of results to return. in: query maximum: 200 @@ -8463,11 +8461,13 @@ paths: /api/v2/admin/accounts: get: description: |- + Returned accounts will be ordered alphabetically (a-z) by domain + username. + The next and previous queries can be parsed from the returned Link header. Example: ``` - ; rel="next", ; rel="prev" + ; rel="next", ; rel="prev" ```` operationId: adminAccountsGetV2 parameters: @@ -8513,19 +8513,15 @@ paths: in: query name: ip type: string - - description: All results returned will be older than the item with this ID. + - description: max_id in the form `[domain]/@[username]`. All results returned will be later in the alphabet than `[domain]/@[username]`. For example, if max_id = `example.org/@someone` then returned entries might contain `example.org/@someone_else`, `later.example.org/@someone`, etc. Local account IDs in this form use an empty string for the `[domain]` part, for example local account with username `someone` would be `/@someone`. in: query name: max_id type: string - - description: All results returned will be newer than the item with this ID. - in: query - name: since_id - type: string - - description: Returns results immediately newer than the item with this ID. + - description: min_id in the form `[domain]/@[username]`. All results returned will be earlier in the alphabet than `[domain]/@[username]`. For example, if min_id = `example.org/@someone` then returned entries might contain `example.org/@earlier_account`, `earlier.example.org/@someone`, etc. Local account IDs in this form use an empty string for the `[domain]` part, for example local account with username `someone` would be `/@someone`. in: query name: min_id type: string - - default: 100 + - default: 50 description: Maximum number of results to return. in: query maximum: 200 diff --git a/internal/api/client/admin/accountsgetv1.go b/internal/api/client/admin/accountsgetv1.go index 604d74992..f333492de 100644 --- a/internal/api/client/admin/accountsgetv1.go +++ b/internal/api/client/admin/accountsgetv1.go @@ -19,11 +19,13 @@ // // View + page through known accounts according to given filters. // +// Returned accounts will be ordered alphabetically (a-z) by domain + username. +// // The next and previous queries can be parsed from the returned Link header. // Example: // // ``` -// ; rel="next", ; rel="prev" +// ; rel="next", ; rel="prev" // ```` // // --- @@ -117,23 +119,30 @@ // name: max_id // in: query // type: string -// description: All results returned will be older than the item with this ID. -// - -// name: since_id -// in: query -// type: string -// description: All results returned will be newer than the item with this ID. +// description: >- +// max_id in the form `[domain]/@[username]`. +// All results returned will be later in the alphabet than `[domain]/@[username]`. +// For example, if max_id = `example.org/@someone` then returned entries might +// contain `example.org/@someone_else`, `later.example.org/@someone`, etc. +// Local account IDs in this form use an empty string for the `[domain]` part, +// for example local account with username `someone` would be `/@someone`. // - // name: min_id // in: query // type: string -// description: Returns results immediately newer than the item with this ID. +// description: >- +// min_id in the form `[domain]/@[username]`. +// All results returned will be earlier in the alphabet than `[domain]/@[username]`. +// For example, if min_id = `example.org/@someone` then returned entries might +// contain `example.org/@earlier_account`, `earlier.example.org/@someone`, etc. +// Local account IDs in this form use an empty string for the `[domain]` part, +// for example local account with username `someone` would be `/@someone`. // - // name: limit // in: query // type: integer // description: Maximum number of results to return. -// default: 100 +// default: 50 // maximum: 200 // minimum: 1 // @@ -200,7 +209,7 @@ func (m *Module) AccountsGETV1Handler(c *gin.Context) { return } - page, errWithCode := paging.ParseIDPage(c, 1, 200, 100) + page, errWithCode := paging.ParseIDPage(c, 1, 200, 50) if errWithCode != nil { apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return diff --git a/internal/api/client/admin/accountsgetv2.go b/internal/api/client/admin/accountsgetv2.go index ca32b9e7f..27024e7a2 100644 --- a/internal/api/client/admin/accountsgetv2.go +++ b/internal/api/client/admin/accountsgetv2.go @@ -19,11 +19,13 @@ // // View + page through known accounts according to given filters. // +// Returned accounts will be ordered alphabetically (a-z) by domain + username. +// // The next and previous queries can be parsed from the returned Link header. // Example: // // ``` -// ; rel="next", ; rel="prev" +// ; rel="next", ; rel="prev" // ```` // // --- @@ -90,23 +92,30 @@ // name: max_id // in: query // type: string -// description: All results returned will be older than the item with this ID. -// - -// name: since_id -// in: query -// type: string -// description: All results returned will be newer than the item with this ID. +// description: >- +// max_id in the form `[domain]/@[username]`. +// All results returned will be later in the alphabet than `[domain]/@[username]`. +// For example, if max_id = `example.org/@someone` then returned entries might +// contain `example.org/@someone_else`, `later.example.org/@someone`, etc. +// Local account IDs in this form use an empty string for the `[domain]` part, +// for example local account with username `someone` would be `/@someone`. // - // name: min_id // in: query // type: string -// description: Returns results immediately newer than the item with this ID. +// description: >- +// min_id in the form `[domain]/@[username]`. +// All results returned will be earlier in the alphabet than `[domain]/@[username]`. +// For example, if min_id = `example.org/@someone` then returned entries might +// contain `example.org/@earlier_account`, `earlier.example.org/@someone`, etc. +// Local account IDs in this form use an empty string for the `[domain]` part, +// for example local account with username `someone` would be `/@someone`. // - // name: limit // in: query // type: integer // description: Maximum number of results to return. -// default: 100 +// default: 50 // maximum: 200 // minimum: 1 // @@ -173,7 +182,7 @@ func (m *Module) AccountsGETV2Handler(c *gin.Context) { return } - page, errWithCode := paging.ParseIDPage(c, 1, 200, 100) + page, errWithCode := paging.ParseIDPage(c, 1, 200, 50) if errWithCode != nil { apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return diff --git a/internal/api/client/admin/accountsgetv2_test.go b/internal/api/client/admin/accountsgetv2_test.go new file mode 100644 index 000000000..fdd6c6c30 --- /dev/null +++ b/internal/api/client/admin/accountsgetv2_test.go @@ -0,0 +1,546 @@ +// 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 admin_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/admin" +) + +type AccountsGetTestSuite struct { + AdminStandardTestSuite +} + +func (suite *AccountsGetTestSuite) TestAccountsGetFromTop() { + recorder := httptest.NewRecorder() + + path := admin.AccountsV2Path + ctx := suite.newContext(recorder, http.MethodGet, nil, path, "application/json") + + suite.adminModule.AccountsGETV2Handler(ctx) + suite.Equal(http.StatusOK, recorder.Code) + + b, err := io.ReadAll(recorder.Body) + if err != nil { + suite.FailNow(err.Error()) + } + suite.NotNil(b) + + dst := new(bytes.Buffer) + err = json.Indent(dst, b, "", " ") + if err != nil { + suite.FailNow(err.Error()) + } + + link := recorder.Header().Get("Link") + suite.Equal(`; rel="next", ; rel="prev"`, link) + + suite.Equal(`[ + { + "id": "01F8MH5NBDF2MV7CTC4Q5128HF", + "username": "1happyturtle", + "domain": null, + "created_at": "2022-06-04T13:12:00.000Z", + "email": "tortle.dude@example.org", + "ip": null, + "ips": [], + "locale": "en", + "invite_request": null, + "role": { + "name": "user" + }, + "confirmed": true, + "approved": true, + "disabled": false, + "silenced": false, + "suspended": false, + "account": { + "id": "01F8MH5NBDF2MV7CTC4Q5128HF", + "username": "1happyturtle", + "acct": "1happyturtle", + "display_name": "happy little turtle :3", + "locked": true, + "discoverable": false, + "bot": false, + "created_at": "2022-06-04T13:12:00.000Z", + "note": "

i post about things that concern me

", + "url": "http://localhost:8080/@1happyturtle", + "avatar": "", + "avatar_static": "", + "header": "http://localhost:8080/assets/default_header.png", + "header_static": "http://localhost:8080/assets/default_header.png", + "followers_count": 1, + "following_count": 1, + "statuses_count": 8, + "last_status_at": "2021-07-28T08:40:37.000Z", + "emojis": [], + "fields": [ + { + "name": "should you follow me?", + "value": "maybe!", + "verified_at": null + }, + { + "name": "age", + "value": "120", + "verified_at": null + } + ], + "hide_collections": true, + "role": { + "name": "user" + } + }, + "created_by_application_id": "01F8MGY43H3N2C8EWPR2FPYEXG" + }, + { + "id": "01F8MH17FWEB39HZJ76B6VXSKF", + "username": "admin", + "domain": null, + "created_at": "2022-05-17T13:10:59.000Z", + "email": "admin@example.org", + "ip": null, + "ips": [], + "locale": "en", + "invite_request": null, + "role": { + "name": "admin" + }, + "confirmed": true, + "approved": true, + "disabled": false, + "silenced": false, + "suspended": false, + "account": { + "id": "01F8MH17FWEB39HZJ76B6VXSKF", + "username": "admin", + "acct": "admin", + "display_name": "", + "locked": false, + "discoverable": true, + "bot": false, + "created_at": "2022-05-17T13:10:59.000Z", + "note": "", + "url": "http://localhost:8080/@admin", + "avatar": "", + "avatar_static": "", + "header": "http://localhost:8080/assets/default_header.png", + "header_static": "http://localhost:8080/assets/default_header.png", + "followers_count": 1, + "following_count": 1, + "statuses_count": 4, + "last_status_at": "2021-10-20T10:41:37.000Z", + "emojis": [], + "fields": [], + "enable_rss": true, + "role": { + "name": "admin" + } + }, + "created_by_application_id": "01F8MGXQRHYF5QPMTMXP78QC2F" + }, + { + "id": "01AY6P665V14JJR0AFVRT7311Y", + "username": "localhost:8080", + "domain": null, + "created_at": "2020-05-17T13:10:59.000Z", + "email": "", + "ip": null, + "ips": [], + "locale": "", + "invite_request": null, + "role": { + "name": "user" + }, + "confirmed": false, + "approved": false, + "disabled": false, + "silenced": false, + "suspended": false, + "account": { + "id": "01AY6P665V14JJR0AFVRT7311Y", + "username": "localhost:8080", + "acct": "localhost:8080", + "display_name": "", + "locked": false, + "discoverable": true, + "bot": false, + "created_at": "2020-05-17T13:10:59.000Z", + "note": "", + "url": "http://localhost:8080/@localhost:8080", + "avatar": "", + "avatar_static": "", + "header": "http://localhost:8080/assets/default_header.png", + "header_static": "http://localhost:8080/assets/default_header.png", + "followers_count": 0, + "following_count": 0, + "statuses_count": 0, + "last_status_at": null, + "emojis": [], + "fields": [] + } + }, + { + "id": "01F8MH1H7YV1Z7D2C8K2730QBF", + "username": "the_mighty_zork", + "domain": null, + "created_at": "2022-05-20T11:09:18.000Z", + "email": "zork@example.org", + "ip": null, + "ips": [], + "locale": "en", + "invite_request": "I wanna be on this damned webbed site so bad! Please! Wow", + "role": { + "name": "user" + }, + "confirmed": true, + "approved": true, + "disabled": false, + "silenced": false, + "suspended": false, + "account": { + "id": "01F8MH1H7YV1Z7D2C8K2730QBF", + "username": "the_mighty_zork", + "acct": "the_mighty_zork", + "display_name": "original zork (he/they)", + "locked": false, + "discoverable": true, + "bot": false, + "created_at": "2022-05-20T11:09:18.000Z", + "note": "

hey yo this is my profile!

", + "url": "http://localhost:8080/@the_mighty_zork", + "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", + "avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpg", + "header": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", + "header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", + "followers_count": 2, + "following_count": 2, + "statuses_count": 7, + "last_status_at": "2023-12-10T09:24:00.000Z", + "emojis": [], + "fields": [], + "enable_rss": true, + "role": { + "name": "user" + } + }, + "created_by_application_id": "01F8MGY43H3N2C8EWPR2FPYEXG" + }, + { + "id": "01F8MH0BBE4FHXPH513MBVFHB0", + "username": "weed_lord420", + "domain": null, + "created_at": "2022-06-04T13:12:00.000Z", + "email": "weed_lord420@example.org", + "ip": "199.222.111.89", + "ips": [], + "locale": "en", + "invite_request": "hi, please let me in! I'm looking for somewhere neato bombeato to hang out.", + "role": { + "name": "user" + }, + "confirmed": false, + "approved": false, + "disabled": false, + "silenced": false, + "suspended": false, + "account": { + "id": "01F8MH0BBE4FHXPH513MBVFHB0", + "username": "weed_lord420", + "acct": "weed_lord420", + "display_name": "", + "locked": false, + "discoverable": false, + "bot": false, + "created_at": "2022-06-04T13:12:00.000Z", + "note": "", + "url": "http://localhost:8080/@weed_lord420", + "avatar": "", + "avatar_static": "", + "header": "http://localhost:8080/assets/default_header.png", + "header_static": "http://localhost:8080/assets/default_header.png", + "followers_count": 0, + "following_count": 0, + "statuses_count": 0, + "last_status_at": null, + "emojis": [], + "fields": [], + "role": { + "name": "user" + } + }, + "created_by_application_id": "01F8MGY43H3N2C8EWPR2FPYEXG" + }, + { + "id": "01FHMQX3GAABWSM0S2VZEC2SWC", + "username": "Some_User", + "domain": "example.org", + "created_at": "2020-08-10T12:13:28.000Z", + "email": "", + "ip": null, + "ips": [], + "locale": "", + "invite_request": null, + "role": { + "name": "user" + }, + "confirmed": false, + "approved": false, + "disabled": false, + "silenced": false, + "suspended": false, + "account": { + "id": "01FHMQX3GAABWSM0S2VZEC2SWC", + "username": "Some_User", + "acct": "Some_User@example.org", + "display_name": "some user", + "locked": true, + "discoverable": true, + "bot": false, + "created_at": "2020-08-10T12:13:28.000Z", + "note": "i'm a real son of a gun", + "url": "http://example.org/@Some_User", + "avatar": "", + "avatar_static": "", + "header": "http://localhost:8080/assets/default_header.png", + "header_static": "http://localhost:8080/assets/default_header.png", + "followers_count": 0, + "following_count": 0, + "statuses_count": 1, + "last_status_at": "2023-11-02T10:44:25.000Z", + "emojis": [], + "fields": [] + } + }, + { + "id": "01F8MH5ZK5VRH73AKHQM6Y9VNX", + "username": "foss_satan", + "domain": "fossbros-anonymous.io", + "created_at": "2021-09-26T10:52:36.000Z", + "email": "", + "ip": null, + "ips": [], + "locale": "", + "invite_request": null, + "role": { + "name": "user" + }, + "confirmed": false, + "approved": false, + "disabled": false, + "silenced": false, + "suspended": false, + "account": { + "id": "01F8MH5ZK5VRH73AKHQM6Y9VNX", + "username": "foss_satan", + "acct": "foss_satan@fossbros-anonymous.io", + "display_name": "big gerald", + "locked": false, + "discoverable": true, + "bot": false, + "created_at": "2021-09-26T10:52:36.000Z", + "note": "i post about like, i dunno, stuff, or whatever!!!!", + "url": "http://fossbros-anonymous.io/@foss_satan", + "avatar": "", + "avatar_static": "", + "header": "http://localhost:8080/assets/default_header.png", + "header_static": "http://localhost:8080/assets/default_header.png", + "followers_count": 0, + "following_count": 0, + "statuses_count": 3, + "last_status_at": "2021-09-11T09:40:37.000Z", + "emojis": [], + "fields": [] + } + }, + { + "id": "062G5WYKY35KKD12EMSM3F8PJ8", + "username": "her_fuckin_maj", + "domain": "thequeenisstillalive.technology", + "created_at": "2020-08-10T12:13:28.000Z", + "email": "", + "ip": null, + "ips": [], + "locale": "", + "invite_request": null, + "role": { + "name": "user" + }, + "confirmed": false, + "approved": false, + "disabled": false, + "silenced": false, + "suspended": false, + "account": { + "id": "062G5WYKY35KKD12EMSM3F8PJ8", + "username": "her_fuckin_maj", + "acct": "her_fuckin_maj@thequeenisstillalive.technology", + "display_name": "lizzzieeeeeeeeeeee", + "locked": true, + "discoverable": true, + "bot": false, + "created_at": "2020-08-10T12:13:28.000Z", + "note": "if i die blame charles don't let that fuck become king", + "url": "http://thequeenisstillalive.technology/@her_fuckin_maj", + "avatar": "", + "avatar_static": "", + "header": "http://localhost:8080/fileserver/062G5WYKY35KKD12EMSM3F8PJ8/header/original/01PFPMWK2FF0D9WMHEJHR07C3R.jpg", + "header_static": "http://localhost:8080/fileserver/062G5WYKY35KKD12EMSM3F8PJ8/header/small/01PFPMWK2FF0D9WMHEJHR07C3R.jpg", + "followers_count": 0, + "following_count": 0, + "statuses_count": 0, + "last_status_at": null, + "emojis": [], + "fields": [] + } + }, + { + "id": "07GZRBAEMBNKGZ8Z9VSKSXKR98", + "username": "üser", + "domain": "ëxample.org", + "created_at": "2020-08-10T12:13:28.000Z", + "email": "", + "ip": null, + "ips": [], + "locale": "", + "invite_request": null, + "role": { + "name": "user" + }, + "confirmed": false, + "approved": false, + "disabled": false, + "silenced": false, + "suspended": false, + "account": { + "id": "07GZRBAEMBNKGZ8Z9VSKSXKR98", + "username": "üser", + "acct": "üser@ëxample.org", + "display_name": "", + "locked": false, + "discoverable": false, + "bot": false, + "created_at": "2020-08-10T12:13:28.000Z", + "note": "", + "url": "https://xn--xample-ova.org/users/@%C3%BCser", + "avatar": "", + "avatar_static": "", + "header": "http://localhost:8080/assets/default_header.png", + "header_static": "http://localhost:8080/assets/default_header.png", + "followers_count": 0, + "following_count": 0, + "statuses_count": 0, + "last_status_at": null, + "emojis": [], + "fields": [] + } + } +]`, dst.String()) +} + +func (suite *AccountsGetTestSuite) TestAccountsMinID() { + recorder := httptest.NewRecorder() + + path := admin.AccountsV2Path + "?limit=1&min_id=/@the_mighty_zork" + ctx := suite.newContext(recorder, http.MethodGet, nil, path, "application/json") + + ctx.Params = gin.Params{ + { + Key: "min_id", + Value: "/@the_mighty_zork", + }, + { + Key: "limit", + Value: "1", + }, + } + + suite.adminModule.AccountsGETV2Handler(ctx) + suite.Equal(http.StatusOK, recorder.Code) + + b, err := io.ReadAll(recorder.Body) + if err != nil { + suite.FailNow(err.Error()) + } + suite.NotNil(b) + + dst := new(bytes.Buffer) + err = json.Indent(dst, b, "", " ") + if err != nil { + suite.FailNow(err.Error()) + } + + link := recorder.Header().Get("Link") + suite.Equal(`; rel="next", ; rel="prev"`, link) + + suite.Equal(`[ + { + "id": "01AY6P665V14JJR0AFVRT7311Y", + "username": "localhost:8080", + "domain": null, + "created_at": "2020-05-17T13:10:59.000Z", + "email": "", + "ip": null, + "ips": [], + "locale": "", + "invite_request": null, + "role": { + "name": "user" + }, + "confirmed": false, + "approved": false, + "disabled": false, + "silenced": false, + "suspended": false, + "account": { + "id": "01AY6P665V14JJR0AFVRT7311Y", + "username": "localhost:8080", + "acct": "localhost:8080", + "display_name": "", + "locked": false, + "discoverable": true, + "bot": false, + "created_at": "2020-05-17T13:10:59.000Z", + "note": "", + "url": "http://localhost:8080/@localhost:8080", + "avatar": "", + "avatar_static": "", + "header": "http://localhost:8080/assets/default_header.png", + "header_static": "http://localhost:8080/assets/default_header.png", + "followers_count": 0, + "following_count": 0, + "statuses_count": 0, + "last_status_at": null, + "emojis": [], + "fields": [] + } + } +]`, dst.String()) +} + +func TestAccountsGetTestSuite(t *testing.T) { + suite.Run(t, &AccountsGetTestSuite{}) +} diff --git a/internal/api/client/statuses/statushistory_test.go b/internal/api/client/statuses/statushistory_test.go index e524e9239..a0cb3d482 100644 --- a/internal/api/client/statuses/statushistory_test.go +++ b/internal/api/client/statuses/statushistory_test.go @@ -50,10 +50,10 @@ func (suite *StatusHistoryTestSuite) TestGetHistory() { // Setup request. recorder := httptest.NewRecorder() - request := httptest.NewRequest(http.MethodGet, target, nil) + request := httptest.NewRequest(http.MethodGet, target, nil) request.Header.Set("accept", "application/json") ctx, _ := testrig.CreateGinTestContext(recorder, request) - + // Set auth + path params. ctx.Set(oauth.SessionAuthorizedApplication, testApplication) ctx.Set(oauth.SessionAuthorizedToken, testToken) diff --git a/internal/db/bundb/account.go b/internal/db/bundb/account.go index 2b3c78aff..4e969e0ef 100644 --- a/internal/db/bundb/account.go +++ b/internal/db/bundb/account.go @@ -252,6 +252,32 @@ func (a *accountDB) GetInstanceAccount(ctx context.Context, domain string) (*gts return a.GetAccountByUsernameDomain(ctx, username, domain) } +// GetAccounts selects accounts using the given parameters. +// Unlike with other functions, the paging for GetAccounts +// is done not by ID, but by a concatenation of `[domain]/@[username]`, +// which allows callers to page through accounts in alphabetical +// order (much more useful for an admin overview of accounts, +// for example, than paging by ID (which is random) or by account +// created at date, which is not particularly interesting). +// +// Generated queries will look something like this +// (SQLite example, maxID was provided so we're paging down): +// +// SELECT "account"."id", (COALESCE("domain", '') || '/@' || "username") AS "domain_username" +// FROM "accounts" AS "account" +// WHERE ("domain_username" > '/@the_mighty_zork') +// ORDER BY "domain_username" ASC +// +// **NOTE ABOUT POSTGRES**: Postgres ordering expressions in +// this function specify COLLATE "C" to ensure that ordering +// is similar to SQLite (which uses BINARY ordering by default). +// This unfortunately means that A-Z > a-z, when ordering but +// that's an acceptable tradeoff for a query like this. +// +// See: +// +// - https://www.postgresql.org/docs/current/collation.html#COLLATION-MANAGING-STANDARD +// - https://sqlite.org/datatype3.html#collation func (a *accountDB) GetAccounts( ctx context.Context, origin string, @@ -269,6 +295,11 @@ func (a *accountDB) GetAccounts( error, ) { var ( + // We have to use different + // syntax for this query + // depending on dialect. + dbDialect = a.db.Dialect().Name() + // local users lists, // required for some // limiting parameters. @@ -287,10 +318,6 @@ func (a *accountDB) GetAccounts( } // Get paging params. - // - // Note this may be min_id OR since_id - // from the API, this gets handled below - // when checking order to reverse slice. minID = page.GetMin() maxID = page.GetMax() limit = page.GetLimit() @@ -309,32 +336,50 @@ func (a *accountDB) GetAccounts( // Select only IDs from table Column("account.id") - // Return only accounts OLDER - // than account with maxID. - if maxID != "" { - maxIDAcct, err := a.GetAccountByID( - gtscontext.SetBarebones(ctx), - maxID, + var subQ *bun.RawQuery + if dbDialect == dialect.SQLite { + // For SQLite we can just select + // our indexed expression once + // as a column alias. + q = q.ColumnExpr( + "(COALESCE(?, ?) || ? || ?) AS ?", + bun.Ident("domain"), "", + "/@", + bun.Ident("username"), + bun.Ident("domain_username"), + ) + } else { + // Create a subquery for + // Postgres to reuse. + subQ = a.db.NewRaw( + "(COALESCE(?, ?) || ? || ?) COLLATE ?", + bun.Ident("domain"), "", + "/@", + bun.Ident("username"), + bun.Ident("C"), ) - if err != nil { - return nil, fmt.Errorf("error getting maxID account %s: %w", maxID, err) - } - - q = q.Where("? < ?", bun.Ident("account.created_at"), maxIDAcct.CreatedAt) } - // Return only accounts NEWER - // than account with minID. - if minID != "" { - minIDAcct, err := a.GetAccountByID( - gtscontext.SetBarebones(ctx), - minID, - ) - if err != nil { - return nil, fmt.Errorf("error getting minID account %s: %w", minID, err) + // Return only accounts with `[domain]/@[username]` + // later in the alphabet (a-z) than provided maxID. + if maxID != "" { + if dbDialect == dialect.SQLite { + // Use aliased column. + q = q.Where("? > ?", bun.Ident("domain_username"), maxID) + } else { + q = q.Where("? > ?", subQ, maxID) } + } - q = q.Where("? > ?", bun.Ident("account.created_at"), minIDAcct.CreatedAt) + // Return only accounts with `[domain]/@[username]` + // earlier in the alphabet (a-z) than provided minID. + if minID != "" { + if dbDialect == dialect.SQLite { + // Use aliased column. + q = q.Where("? < ?", bun.Ident("domain_username"), minID) + } else { + q = q.Where("? < ?", subQ, minID) + } } switch status { @@ -479,13 +524,29 @@ func (a *accountDB) GetAccounts( if order == paging.OrderAscending { // Page up. - q = q.Order("account.created_at ASC") + // It's counterintuitive because it + // says DESC in the query, but we're + // going backwards in the alphabet, + // and a < z in a string comparison. + if dbDialect == dialect.SQLite { + q = q.OrderExpr("? DESC", bun.Ident("domain_username")) + } else { + q = q.OrderExpr("(?) DESC", subQ) + } } else { // Page down. - q = q.Order("account.created_at DESC") + // It's counterintuitive because it + // says ASC in the query, but we're + // going forwards in the alphabet, + // and z > a in a string comparison. + if dbDialect == dialect.SQLite { + q = q.OrderExpr("? ASC", bun.Ident("domain_username")) + } else { + q = q.OrderExpr("? ASC", subQ) + } } - if err := q.Scan(ctx, &accountIDs); err != nil { + if err := q.Scan(ctx, &accountIDs, new([]string)); err != nil { return nil, err } diff --git a/internal/db/bundb/account_test.go b/internal/db/bundb/account_test.go index ea211e16f..5ed5d91a1 100644 --- a/internal/db/bundb/account_test.go +++ b/internal/db/bundb/account_test.go @@ -502,6 +502,80 @@ func (suite *AccountTestSuite) TestGetAccountsAll() { suite.Len(accounts, 9) } +func (suite *AccountTestSuite) TestGetAccountsMaxID() { + var ( + ctx = context.Background() + origin = "" + status = "" + mods = false + invitedBy = "" + username = "" + displayName = "" + domain = "" + email = "" + ip netip.Addr + // Get accounts with `[domain]/@[username]` + // later in the alphabet than `/@the_mighty_zork`. + page = &paging.Page{Max: paging.MaxID("/@the_mighty_zork")} + ) + + accounts, err := suite.db.GetAccounts( + ctx, + origin, + status, + mods, + invitedBy, + username, + displayName, + domain, + email, + ip, + page, + ) + if err != nil { + suite.FailNow(err.Error()) + } + + suite.Len(accounts, 5) +} + +func (suite *AccountTestSuite) TestGetAccountsMinID() { + var ( + ctx = context.Background() + origin = "" + status = "" + mods = false + invitedBy = "" + username = "" + displayName = "" + domain = "" + email = "" + ip netip.Addr + // Get accounts with `[domain]/@[username]` + // earlier in the alphabet than `/@the_mighty_zork`. + page = &paging.Page{Min: paging.MinID("/@the_mighty_zork")} + ) + + accounts, err := suite.db.GetAccounts( + ctx, + origin, + status, + mods, + invitedBy, + username, + displayName, + domain, + email, + ip, + page, + ) + if err != nil { + suite.FailNow(err.Error()) + } + + suite.Len(accounts, 3) +} + func (suite *AccountTestSuite) TestGetAccountsModsOnly() { var ( ctx = context.Background() diff --git a/internal/db/bundb/migrations/20240426122821_pageable_admin_accounts.go b/internal/db/bundb/migrations/20240426122821_pageable_admin_accounts.go new file mode 100644 index 000000000..00465cc85 --- /dev/null +++ b/internal/db/bundb/migrations/20240426122821_pageable_admin_accounts.go @@ -0,0 +1,84 @@ +// 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 migrations + +import ( + "context" + + "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/uptrace/bun" + "github.com/uptrace/bun/dialect" +) + +func init() { + up := func(ctx context.Context, db *bun.DB) error { + log.Info(ctx, "reindexing accounts (accounts_paging_idx); this may take a few minutes, please don't interrupt this migration!") + + q := db.NewCreateIndex(). + TableExpr("accounts"). + Index("accounts_paging_idx"). + IfNotExists() + + switch d := db.Dialect().Name(); d { + case dialect.SQLite: + q = q.ColumnExpr( + "COALESCE(?, ?) || ? || ?", + bun.Ident("domain"), "", + "/@", + bun.Ident("username"), + ) + + // Specify C collation for Postgres to ensure + // alphabetic sort order is similar enough to + // SQLite (which uses BINARY sort by default). + // + // See: + // + // - https://www.postgresql.org/docs/current/collation.html#COLLATION-MANAGING-STANDARD + // - https://sqlite.org/datatype3.html#collation + case dialect.PG: + q = q.ColumnExpr( + "(COALESCE(?, ?) || ? || ?) COLLATE ?", + bun.Ident("domain"), "", + "/@", + bun.Ident("username"), + bun.Ident("C"), + ) + + default: + log.Panicf(ctx, "dialect %s was neither postgres nor sqlite", d) + } + + if _, err := q.Exec(ctx); err != nil { + return err + } + + return nil + + } + + down := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + return nil + }) + } + + if err := Migrations.Register(up, down); err != nil { + panic(err) + } +} diff --git a/internal/processing/admin/accounts.go b/internal/processing/admin/accounts.go index ca35b0a30..ba2a88ce6 100644 --- a/internal/processing/admin/accounts.go +++ b/internal/processing/admin/accounts.go @@ -115,8 +115,12 @@ func() bool { return request.Permissions == "staff" }(), return paging.EmptyResponse(), nil } - hi := accounts[count-1].ID - lo := accounts[0].ID + var ( + loAcct = accounts[count-1] + hiAcct = accounts[0] + lo = loAcct.Domain + "/@" + loAcct.Username + hi = hiAcct.Domain + "/@" + hiAcct.Username + ) items := make([]interface{}, 0, count) for _, account := range accounts { diff --git a/web/source/package.json b/web/source/package.json index 919bf3c83..230c248ad 100644 --- a/web/source/package.json +++ b/web/source/package.json @@ -22,6 +22,7 @@ "nanoid": "^4.0.0", "object-to-formdata": "^4.4.2", "papaparse": "^5.3.2", + "parse-link-header": "^2.0.0", "photoswipe": "^5.3.3", "photoswipe-dynamic-caption-plugin": "^1.2.7", "plyr": "^3.7.8", @@ -44,6 +45,7 @@ "@joepie91/eslint-config": "^1.1.1", "@types/is-valid-domain": "^0.0.2", "@types/papaparse": "^5.3.9", + "@types/parse-link-header": "^2.0.3", "@types/psl": "^1.1.1", "@types/react-dom": "^18.2.8", "@typescript-eslint/eslint-plugin": "^6.7.4", diff --git a/web/source/settings/components/account-list.tsx b/web/source/settings/components/account-list.tsx deleted file mode 100644 index c4420b5bc..000000000 --- a/web/source/settings/components/account-list.tsx +++ /dev/null @@ -1,82 +0,0 @@ -/* - 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 { Link } from "wouter"; -import { Error } from "./error"; -import { AdminAccount } from "../lib/types/account"; -import { SerializedError } from "@reduxjs/toolkit"; -import { FetchBaseQueryError } from "@reduxjs/toolkit/query"; - -export interface AccountListProps { - isSuccess: boolean, - data: AdminAccount[] | undefined, - isLoading: boolean, - isError: boolean, - error: FetchBaseQueryError | SerializedError | undefined, - emptyMessage: string, -} - -export function AccountList({ - isLoading, - isSuccess, - data, - isError, - error, - emptyMessage, -}: AccountListProps) { - if (!(isSuccess || isError)) { - // Hasn't been called yet. - return null; - } - - if (isLoading) { - return