summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/api/client/admin/accountsgetv1.go29
-rw-r--r--internal/api/client/admin/accountsgetv2.go29
-rw-r--r--internal/api/client/admin/accountsgetv2_test.go546
-rw-r--r--internal/api/client/statuses/statushistory_test.go4
-rw-r--r--internal/db/bundb/account.go115
-rw-r--r--internal/db/bundb/account_test.go74
-rw-r--r--internal/db/bundb/migrations/20240426122821_pageable_admin_accounts.go84
-rw-r--r--internal/processing/admin/accounts.go8
8 files changed, 838 insertions, 51 deletions
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:
//
// ```
-// <https://example.org/api/v1/admin/accounts?limit=80&max_id=01FC0SKA48HNSVR6YKZCQGS2V8>; rel="next", <https://example.org/api/v1/admin/accounts?limit=80&min_id=01FC0SKW5JK2Q4EVAV2B462YY0>; rel="prev"
+// <https://example.org/api/v1/admin/accounts?limit=80&max_id=example.org%2F%40someone>; rel="next", <https://example.org/api/v1/admin/accounts?limit=80&min_id=example.org%2F%40someone_else>; 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:
//
// ```
-// <https://example.org/api/v2/admin/accounts?limit=80&max_id=01FC0SKA48HNSVR6YKZCQGS2V8>; rel="next", <https://example.org/api/v2/admin/accounts?limit=80&min_id=01FC0SKW5JK2Q4EVAV2B462YY0>; rel="prev"
+// <https://example.org/api/v2/admin/accounts?limit=80&max_id=example.org%2F%40someone>; rel="next", <https://example.org/api/v2/admin/accounts?limit=80&min_id=example.org%2F%40someone_else>; 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 <http://www.gnu.org/licenses/>.
+
+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(`<http://localhost:8080/api/v2/admin/accounts?limit=50&max_id=xn--xample-ova.org%2F%40%C3%BCser>; rel="next", <http://localhost:8080/api/v2/admin/accounts?limit=50&min_id=%2F%401happyturtle>; 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": "<p>i post about things that concern me</p>",
+ "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": "<p>hey yo this is my profile!</p>",
+ "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(`<http://localhost:8080/api/v2/admin/accounts?limit=1&max_id=%2F%40localhost%3A8080>; rel="next", <http://localhost:8080/api/v2/admin/accounts?limit=1&min_id=%2F%40localhost%3A8080>; 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"),
)
- if err != nil {
- return nil, fmt.Errorf("error getting maxID account %s: %w", maxID, err)
- }
+ } else {
+ // Create a subquery for
+ // Postgres to reuse.
+ subQ = a.db.NewRaw(
+ "(COALESCE(?, ?) || ? || ?) COLLATE ?",
+ bun.Ident("domain"), "",
+ "/@",
+ bun.Ident("username"),
+ bun.Ident("C"),
+ )
+ }
- q = q.Where("? < ?", bun.Ident("account.created_at"), maxIDAcct.CreatedAt)
+ // 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)
+ }
}
- // Return only accounts NEWER
- // than account with minID.
+ // Return only accounts with `[domain]/@[username]`
+ // earlier in the alphabet (a-z) than provided 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)
+ if dbDialect == dialect.SQLite {
+ // Use aliased column.
+ q = q.Where("? < ?", bun.Ident("domain_username"), minID)
+ } else {
+ q = q.Where("? < ?", subQ, minID)
}
-
- q = q.Where("? > ?", bun.Ident("account.created_at"), minIDAcct.CreatedAt)
}
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 <http://www.gnu.org/licenses/>.
+
+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 (p *Processor) AccountsGet(
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 {