This commit is contained in:
tobi 2024-04-29 10:50:39 +00:00 committed by GitHub
commit 5a4714a605
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 890 additions and 97 deletions

View file

@ -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,41 @@
// name: max_id
// in: query
// 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`.
// -
// name: since_id
// in: query
// type: string
// description: All results returned will be newer than the item with this ID.
// description: >-
// since_id in the form `[domain]/@[username]`.
// All results returned will be earlier in the alphabet than `[domain]/@[username]`.
// For example, if since_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: 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 +220,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

View file

@ -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,41 @@
// name: max_id
// in: query
// 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`.
// -
// name: since_id
// in: query
// type: string
// description: All results returned will be newer than the item with this ID.
// description: >-
// since_id in the form `[domain]/@[username]`.
// All results returned will be earlier in the alphabet than `[domain]/@[username]`.
// For example, if since_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: 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 +193,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

View file

@ -0,0 +1,465 @@
// 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/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&since_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 TestAccountsGetTestSuite(t *testing.T) {
suite.Run(t, &AccountsGetTestSuite{})
}

View file

@ -252,6 +252,21 @@ 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
func (a *accountDB) GetAccounts(
ctx context.Context,
origin string,
@ -309,32 +324,40 @@ 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,
// SQLite and Postgres use different syntax
// for concatenation, so switch on type to
// ensure we use the correct syntax.
switch d := a.db.Dialect().Name(); d {
case dialect.SQLite:
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)
}
q = q.Where("? < ?", bun.Ident("account.created_at"), maxIDAcct.CreatedAt)
case dialect.PG:
q = q.ColumnExpr(
"(CONCAT(COALESCE(?, ?), ?, ?)) AS ?",
bun.Ident("domain"), "",
"/@",
bun.Ident("username"),
bun.Ident("domain_username"),
)
default:
log.Panicf(ctx, "dialect %s was neither postgres nor sqlite", d)
}
// 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 != "" {
q = q.Where("? > ?", bun.Ident("domain_username"), 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 != "" {
q = q.Where("? < ?", bun.Ident("domain_username"), minID)
}
switch status {
@ -479,13 +502,21 @@ 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.
q = q.OrderExpr("? DESC", bun.Ident("domain_username"))
} 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.
q = q.OrderExpr("? ASC", bun.Ident("domain_username"))
}
if err := q.Scan(ctx, &accountIDs); err != nil {
if err := q.Scan(ctx, &accountIDs, new([]string)); err != nil {
return nil, err
}

View file

@ -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()

View file

@ -0,0 +1,75 @@
// 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"),
)
case dialect.PG:
q = q.ColumnExpr(
"CONCAT(COALESCE(?, ?), ?, ?)",
bun.Ident("domain"), "",
"/@",
bun.Ident("username"),
)
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)
}
}

View file

@ -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 {

View file

@ -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",

View file

@ -17,12 +17,13 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from "react";
import { Link } from "wouter";
import React, { ReactNode } from "react";
import { Link, useLocation } from "wouter";
import { Error } from "./error";
import { AdminAccount } from "../lib/types/account";
import { SerializedError } from "@reduxjs/toolkit";
import { FetchBaseQueryError } from "@reduxjs/toolkit/query";
import { Links } from "parse-link-header";
export interface AccountListProps {
isSuccess: boolean,
@ -31,6 +32,7 @@ export interface AccountListProps {
isError: boolean,
error: FetchBaseQueryError | SerializedError | undefined,
emptyMessage: string,
links?: Links | null | undefined,
}
export function AccountList({
@ -40,7 +42,10 @@ export function AccountList({
isError,
error,
emptyMessage,
links,
}: AccountListProps) {
const [ location, setLocation ] = useLocation();
if (!(isSuccess || isError)) {
// Hasn't been called yet.
return null;
@ -58,25 +63,63 @@ export function AccountList({
return <Error error={error} />;
}
// Map response to entries if possible.
let content: ReactNode;
if (data == undefined || data.length == 0) {
return <b>{emptyMessage}</b>;
content = <b>{emptyMessage}</b>;
} else {
content = (
<div className="entries">
{data.map(({ account: acc }) => (
<Link
key={acc.acct}
className="account entry"
href={`/${acc.id}`}
>
{acc.display_name?.length > 0
? acc.display_name
: acc.username
}
<span id="username">(@{acc.acct})</span>
</Link>
))}
</div>
);
}
// If it's possible to page to next and previous
// pages, instantiate button handlers for this.
let prevClick: (() => void) | undefined;
let nextClick: (() => void) | undefined;
if (links) {
const prev = links["prev"];
if (prev) {
const prevUrl = new URL(prev.url);
const prevParams = prevUrl.search;
prevClick = () => {
setLocation(location + prevParams.toString());
};
}
const next = links["next"];
if (next) {
const nextUrl = new URL(next.url);
const nextParams = nextUrl.search;
nextClick = () => {
setLocation(location + nextParams.toString());
};
}
}
return (
<div className="list">
{data.map(({ account: acc }) => (
<Link
key={acc.acct}
className="account entry"
href={`/${acc.id}`}
>
{acc.display_name?.length > 0
? acc.display_name
: acc.username
}
<span id="username">(@{acc.acct})</span>
</Link>
))}
{ content }
{ links &&
<div className="prev-next">
{ prevClick && <button onClick={prevClick}>Previous page</button> }
{ nextClick && <button onClick={nextClick}>Next page</button> }
</div>
}
</div>
);
}

View file

@ -20,8 +20,9 @@
import { replaceCacheOnMutation, removeFromCacheOnMutation } from "../query-modifiers";
import { gtsApi } from "../gts-api";
import { listToKeyedObject } from "../transforms";
import { AdminAccount, HandleSignupParams, SearchAccountParams } from "../../types/account";
import { AdminAccount, HandleSignupParams, SearchAccountParams, SearchAccountResp } from "../../types/account";
import { InstanceRule, MappedRules } from "../../types/rules";
import parse from "parse-link-header";
const extended = gtsApi.injectEndpoints({
endpoints: (build) => ({
@ -65,7 +66,7 @@ const extended = gtsApi.injectEndpoints({
],
}),
searchAccounts: build.query<AdminAccount[], SearchAccountParams>({
searchAccounts: build.query<SearchAccountResp, SearchAccountParams>({
query: (form) => {
const params = new(URLSearchParams);
Object.entries(form).forEach(([k, v]) => {
@ -83,10 +84,16 @@ const extended = gtsApi.injectEndpoints({
url: `/api/v2/admin/accounts${query}`
};
},
transformResponse: (apiResp: AdminAccount[], meta) => {
const accounts = apiResp;
const linksStr = meta?.response?.headers.get("Link");
const links = parse(linksStr);
return { accounts, links };
},
providesTags: (res) =>
res
? [
...res.map(({ id }) => ({ type: 'Account' as const, id })),
...res.accounts.map(({ id }) => ({ type: 'Account' as const, id })),
{ type: 'Account', id: 'LIST' },
]
: [{ type: 'Account', id: 'LIST' }],

View file

@ -24,7 +24,7 @@ import type {
FetchBaseQueryError,
} from '@reduxjs/toolkit/query/react';
import { serialize as serializeForm } from "object-to-formdata";
import type { FetchBaseQueryMeta } from "@reduxjs/toolkit/dist/query/fetchBaseQuery";
import type { RootState } from '../../redux/store';
import { InstanceV1 } from '../types/instance';
@ -65,7 +65,9 @@ export interface GTSFetchArgs extends FetchArgs {
const gtsBaseQuery: BaseQueryFn<
string | GTSFetchArgs,
any,
FetchBaseQueryError
FetchBaseQueryError,
{},
FetchBaseQueryMeta
> = async (args, api, extraOptions) => {
// Retrieve state at the moment
// this function was called.

View file

@ -17,6 +17,7 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Links } from "parse-link-header";
import { CustomEmoji } from "./custom-emoji";
export interface AdminAccount {
@ -79,6 +80,11 @@ export interface SearchAccountParams {
limit?: number,
}
export interface SearchAccountResp {
accounts: AdminAccount[];
links: Links | null;
}
export interface HandleSignupParams {
id: string,
approve_or_reject: "approve" | "reject",

View file

@ -1132,17 +1132,26 @@ button.with-padding {
}
.list {
margin: 0.5rem 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
a {
color: $fg;
text-decoration: none;
#username {
color: $link-fg;
margin-left: 0.5em;
.entries {
a {
color: $fg;
text-decoration: none;
#username {
color: $link-fg;
margin-left: 0.5em;
}
}
}
.prev-next {
display: flex;
justify-content: space-between;
}
}
}

View file

@ -20,10 +20,10 @@
import React from "react";
import { AccountSearchForm } from "./search";
export default function AccountsOverview({ }) {
export default function AccountsSearch({ }) {
return (
<div className="accounts-view">
<h1>Accounts Overview</h1>
<h1>Accounts Search</h1>
<span>
You can perform actions on an account by clicking
its name in a report, or by searching for the account

View file

@ -30,7 +30,7 @@ export default function AccountsPending() {
<AccountList
isLoading={searchRes.isLoading}
isSuccess={searchRes.isSuccess}
data={searchRes.data}
data={searchRes.data?.accounts}
isError={searchRes.isError}
error={searchRes.error}
emptyMessage="No pending account sign-ups."

View file

@ -17,28 +17,51 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from "react";
import React, { useEffect, useMemo } from "react";
import { useLazySearchAccountsQuery } from "../../../../lib/query/admin";
import { useTextInput } from "../../../../lib/form";
import { AccountList } from "../../../../components/account-list";
import { SearchAccountParams } from "../../../../lib/types/account";
import { Select, TextInput } from "../../../../components/form/inputs";
import MutationButton from "../../../../components/form/mutation-button";
import { useLocation, useSearch } from "wouter";
export function AccountSearchForm() {
const [ location, setLocation ] = useLocation();
const search = useSearch();
const urlQueryParams = useMemo(() => new URLSearchParams(search), [search]);
const [ searchAcct, searchRes ] = useLazySearchAccountsQuery();
// Populate search form using values from
// urlQueryParams, to allow paging.
const form = {
origin: useTextInput("origin"),
status: useTextInput("status"),
permissions: useTextInput("permissions"),
username: useTextInput("username"),
display_name: useTextInput("display_name"),
by_domain: useTextInput("by_domain"),
email: useTextInput("email"),
ip: useTextInput("ip"),
origin: useTextInput("origin", { defaultValue: urlQueryParams.get("origin") ?? ""}),
status: useTextInput("status", { defaultValue: urlQueryParams.get("status") ?? ""}),
permissions: useTextInput("permissions", { defaultValue: urlQueryParams.get("permissions") ?? ""}),
username: useTextInput("username", { defaultValue: urlQueryParams.get("username") ?? ""}),
display_name: useTextInput("display_name", { defaultValue: urlQueryParams.get("display_name") ?? ""}),
by_domain: useTextInput("by_domain", { defaultValue: urlQueryParams.get("by_domain") ?? ""}),
email: useTextInput("email", { defaultValue: urlQueryParams.get("email") ?? ""}),
ip: useTextInput("ip", { defaultValue: urlQueryParams.get("ip") ?? ""}),
limit: useTextInput("limit", { defaultValue: urlQueryParams.get("limit") ?? "50"})
};
function submitSearch(e) {
// On mount, if urlQueryParams were provided,
// trigger the search. For example, if page
// was accessed at /search?origin=local&limit=20,
// then run a search with origin=local and
// limit=20 and immediately render the results.
useEffect(() => {
if (urlQueryParams.size > 0) {
searchAcct(Object.fromEntries(urlQueryParams), true);
}
}, [urlQueryParams, searchAcct]);
// Rather than triggering the search directly,
// the "submit" button changes the location
// based on form field params, and lets the
// useEffect hook above actually do the search.
function submitQuery(e) {
e.preventDefault();
// Parse query parameters.
@ -52,16 +75,15 @@ export function AccountSearchForm() {
// Remove any nulls.
return kv || [];
});
const params: SearchAccountParams = Object.fromEntries(entries);
searchAcct(params);
}
const [ searchAcct, searchRes ] = useLazySearchAccountsQuery();
const searchParams = new URLSearchParams(entries);
setLocation(location + "?" + searchParams.toString());
}
return (
<>
<form
onSubmit={submitSearch}
onSubmit={submitQuery}
// Prevent password managers trying
// to fill in username/email fields.
autoComplete="off"
@ -120,10 +142,11 @@ export function AccountSearchForm() {
<AccountList
isLoading={searchRes.isLoading}
isSuccess={searchRes.isSuccess}
data={searchRes.data}
data={searchRes.data?.accounts}
isError={searchRes.isError}
error={searchRes.error}
emptyMessage="No accounts found that match your query"
links={searchRes.data?.links}
/>
</>
);

View file

@ -28,7 +28,7 @@ import { useHasPermission } from "../../lib/navigation/util";
/**
* - /settings/moderation/reports/overview
* - /settings/moderation/reports/:reportId
* - /settings/moderation/accounts/overview
* - /settings/moderation/accounts/search
* - /settings/moderation/accounts/pending
* - /settings/moderation/accounts/:accountID
* - /settings/moderation/domain-permissions/:permType
@ -76,12 +76,12 @@ function ModerationAccountsMenu() {
<MenuItem
name="Accounts"
itemUrl="accounts"
defaultChild="overview"
defaultChild="search"
icon="fa-users"
>
<MenuItem
name="Overview"
itemUrl="overview"
name="Search"
itemUrl="search"
icon="fa-list"
/>
<MenuItem

View file

@ -26,7 +26,7 @@ import { ErrorBoundary } from "../../lib/navigation/error";
import ImportExport from "./domain-permissions/import-export";
import DomainPermissionsOverview from "./domain-permissions/overview";
import DomainPermDetail from "./domain-permissions/detail";
import AccountsOverview from "./accounts";
import AccountsSearch from "./accounts";
import AccountsPending from "./accounts/pending";
import AccountDetail from "./accounts/detail";
@ -37,7 +37,7 @@ import AccountDetail from "./accounts/detail";
/**
* - /settings/moderation/reports/overview
* - /settings/moderation/reports/:reportId
* - /settings/moderation/accounts/overview
* - /settings/moderation/accounts/search
* - /settings/moderation/accounts/pending
* - /settings/moderation/accounts/:accountID
* - /settings/moderation/domain-permissions/:permType
@ -95,7 +95,7 @@ function ModerationReportsRouter() {
}
/**
* - /settings/moderation/accounts/overview
* - /settings/moderation/accounts/search
* - /settings/moderation/accounts/pending
* - /settings/moderation/accounts/:accountID
*/
@ -109,10 +109,10 @@ function ModerationAccountsRouter() {
<Router base={thisBase}>
<ErrorBoundary>
<Switch>
<Route path="/overview" component={AccountsOverview}/>
<Route path="/search" component={AccountsSearch}/>
<Route path="/pending" component={AccountsPending}/>
<Route path="/:accountID" component={AccountDetail}/>
<Route><Redirect to="/overview"/></Route>
<Route><Redirect to="/search"/></Route>
</Switch>
</ErrorBoundary>
</Router>

View file

@ -1468,6 +1468,11 @@
dependencies:
"@types/node" "*"
"@types/parse-link-header@^2.0.3":
version "2.0.3"
resolved "https://registry.yarnpkg.com/@types/parse-link-header/-/parse-link-header-2.0.3.tgz#37ad650d12aecb055b64c2d43ddb1534e356ad33"
integrity sha512-ffLAxD6Xqcf2gSbtEJehj8yJ5R/2OZqD4liodQvQQ+hhO4kg1mk9ToEZQPMtNTm/zIQj2GNleQbsjPp9+UQm4Q==
"@types/prop-types@*":
version "15.7.8"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.8.tgz#805eae6e8f41bd19e88917d2ea200dc992f405d3"
@ -5182,6 +5187,13 @@ parse-json@^2.2.0:
dependencies:
error-ex "^1.2.0"
parse-link-header@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/parse-link-header/-/parse-link-header-2.0.0.tgz#949353e284f8aa01f2ac857a98f692b57733f6b7"
integrity sha512-xjU87V0VyHZybn2RrCX5TIFGxTVZE6zqqZWMPlIKiSKuWh/X5WZdt+w1Ki1nXB+8L/KtL+nZ4iq+sfI6MrhhMw==
dependencies:
xtend "~4.0.1"
parse-ms@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/parse-ms/-/parse-ms-2.1.0.tgz#348565a753d4391fa524029956b172cb7753097d"