add admin accounts get, approve, reject

This commit is contained in:
tobi 2024-04-11 12:04:26 +02:00
parent 85a76cc26e
commit e946f36601
25 changed files with 2500 additions and 23 deletions

View file

@ -3680,6 +3680,166 @@ paths:
summary: Verify a token by returning account details pertaining to it.
tags:
- accounts
/api/v1/admin/accounts:
get:
description: |-
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"
````
operationId: adminAccountsGetV1
parameters:
- default: false
description: Filter for local accounts.
in: query
name: local
type: boolean
- default: false
description: Filter for remote accounts.
in: query
name: remote
type: boolean
- default: false
description: Filter for currently active accounts.
in: query
name: active
type: boolean
- default: false
description: Filter for currently pending accounts.
in: query
name: pending
type: boolean
- default: false
description: Filter for currently disabled accounts.
in: query
name: disabled
type: boolean
- default: false
description: Filter for currently silenced accounts.
in: query
name: silenced
type: boolean
- default: false
description: Filter for currently suspended accounts.
in: query
name: suspended
type: boolean
- default: false
description: Filter for accounts force-marked as sensitive.
in: query
name: sensitized
type: boolean
- description: Search for the given username.
in: query
name: username
type: string
- description: Search for the given display name.
in: query
name: display_name
type: string
- description: Filter by the given domain.
in: query
name: by_domain
type: string
- description: Lookup a user with this email.
in: query
name: email
type: string
- description: Lookup users with this IP address.
in: query
name: ip
type: string
- default: false
description: Filter for staff accounts.
in: query
name: staff
type: boolean
- description: All results returned will be older than the item with this ID.
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.
in: query
name: min_id
type: string
- default: 100
description: Maximum number of results to return.
in: query
maximum: 200
minimum: 1
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: ""
headers:
Link:
description: Links to the next and previous queries.
type: string
schema:
items:
$ref: '#/definitions/adminAccountInfo'
type: array
"400":
description: bad request
"401":
description: unauthorized
"403":
description: forbidden
"404":
description: not found
"406":
description: not acceptable
"500":
description: internal server error
security:
- OAuth2 Bearer:
- admin
summary: View + page through known accounts according to given filters.
tags:
- admin
/api/v1/admin/accounts/{id}:
get:
operationId: adminAccountGet
parameters:
- description: ID of the account.
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/adminAccountInfo'
"400":
description: bad request
"401":
description: unauthorized
"403":
description: forbidden
"404":
description: not found
"406":
description: not acceptable
"500":
description: internal server error
security:
- OAuth2 Bearer:
- admin
summary: View one account.
tags:
- admin
/api/v1/admin/accounts/{id}/action:
post:
consumes:
@ -7934,6 +8094,109 @@ paths:
summary: Change the password of authenticated user.
tags:
- user
/api/v2/admin/accounts:
get:
description: |-
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"
````
operationId: adminAccountsGetV2
parameters:
- description: Filter for `local` or `remote` accounts.
in: query
name: origin
type: string
- description: Filter for `active`, `pending`, `disabled`, `silenced`, or `suspended` accounts.
in: query
name: status
type: string
- description: Filter for accounts with staff permissions (users that can manage reports).
in: query
name: permissions
type: string
- description: Filter for users with these roles.
in: query
items:
type: string
name: role_ids[]
type: array
- description: Lookup users invited by the account with this ID.
in: query
name: invited_by
type: string
- description: Search for the given username.
in: query
name: username
type: string
- description: Search for the given display name.
in: query
name: display_name
type: string
- description: Filter by the given domain.
in: query
name: by_domain
type: string
- description: Lookup a user with this email.
in: query
name: email
type: string
- description: Lookup users with this IP address.
in: query
name: ip
type: string
- description: All results returned will be older than the item with this ID.
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.
in: query
name: min_id
type: string
- default: 100
description: Maximum number of results to return.
in: query
maximum: 200
minimum: 1
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: ""
headers:
Link:
description: Links to the next and previous queries.
type: string
schema:
items:
$ref: '#/definitions/adminAccountInfo'
type: array
"400":
description: bad request
"401":
description: unauthorized
"403":
description: forbidden
"404":
description: not found
"406":
description: not acceptable
"500":
description: internal server error
security:
- OAuth2 Bearer:
- admin
summary: View + page through known accounts according to given filters.
tags:
- admin
/api/v2/instance:
get:
operationId: instanceGetV2

View file

@ -0,0 +1,105 @@
// 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
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
// AccountApprovePOSTHandler swagger:operation POST /api/v1/admin/accounts/{id}/approve adminAccountApprove
//
// Approve pending account.
//
// ---
// tags:
// - admin
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// required: true
// in: path
// description: ID of the account.
// type: string
//
// security:
// - OAuth2 Bearer:
// - admin
//
// responses:
// '200':
// description: The now-approved account.
// schema:
// "$ref": "#/definitions/adminAccountInfo"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '403':
// description: forbidden
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) AccountApprovePOSTHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if !*authed.User.Admin {
err := fmt.Errorf("user %s not an admin", authed.User.ID)
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
targetAcctID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
account, errWithCode := m.processor.Admin().AccountApprove(
c.Request.Context(),
authed.Account,
targetAcctID,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, account)
}

View file

@ -0,0 +1,101 @@
// 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
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
// AccountGETHandler swagger:operation GET /api/v1/admin/accounts/{id} adminAccountGet
//
// View one account.
//
// ---
// tags:
// - admin
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// required: true
// in: path
// description: ID of the account.
// type: string
//
// security:
// - OAuth2 Bearer:
// - admin
//
// responses:
// '200':
// description: OK
// schema:
// "$ref": "#/definitions/adminAccountInfo"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '403':
// description: forbidden
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) AccountGETHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if !*authed.User.Admin {
err := fmt.Errorf("user %s not an admin", authed.User.ID)
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
targetAcctID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
account, errWithCode := m.processor.Admin().AccountGet(c.Request.Context(), targetAcctID)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, account)
}

View file

@ -0,0 +1,136 @@
// 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
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
// AccountRejectPOSTHandler swagger:operation POST /api/v1/admin/accounts/{id}/reject adminAccountReject
//
// Reject pending account.
//
// ---
// tags:
// - admin
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// required: true
// in: path
// description: ID of the account.
// type: string
// -
// name: private_comment
// in: formData
// description: >-
// Comment to leave on why the account was denied.
// The comment will be visible to admins only.
// type: string
// -
// name: message
// in: formData
// description: >-
// Message to include in email to applicant.
// Will be included only if send_email is true.
// type: string
// -
// name: send_email
// in: formData
// description: >-
// Send an email to the applicant informing
// them that their sign-up has been rejected.
// type: boolean
//
// security:
// - OAuth2 Bearer:
// - admin
//
// responses:
// '200':
// description: The now-rejected account.
// schema:
// "$ref": "#/definitions/adminAccountInfo"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '403':
// description: forbidden
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) AccountRejectPOSTHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if !*authed.User.Admin {
err := fmt.Errorf("user %s not an admin", authed.User.ID)
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
targetAcctID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
form := new(apimodel.AdminAccountRejectRequest)
if err := c.ShouldBind(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
account, errWithCode := m.processor.Admin().AccountReject(
c.Request.Context(),
authed.Account,
targetAcctID,
form.PrivateComment,
form.SendEmail,
form.Message,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, account)
}

View file

@ -0,0 +1,347 @@
// 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/>.
// AccountsGETHandlerV1 swagger:operation GET /api/v1/admin/accounts adminAccountsGetV1
//
// View + page through known accounts according to given filters.
//
// 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"
// ````
//
// ---
// tags:
// - admin
//
// produces:
// - application/json
//
// parameters:
// -
// name: local
// in: query
// type: boolean
// description: Filter for local accounts.
// default: false
// -
// name: remote
// in: query
// type: boolean
// description: Filter for remote accounts.
// default: false
// -
// name: active
// in: query
// type: boolean
// description: Filter for currently active accounts.
// default: false
// -
// name: pending
// in: query
// type: boolean
// description: Filter for currently pending accounts.
// default: false
// -
// name: disabled
// in: query
// type: boolean
// description: Filter for currently disabled accounts.
// default: false
// -
// name: silenced
// in: query
// type: boolean
// description: Filter for currently silenced accounts.
// default: false
// -
// name: suspended
// in: query
// type: boolean
// description: Filter for currently suspended accounts.
// default: false
// -
// name: sensitized
// in: query
// type: boolean
// description: Filter for accounts force-marked as sensitive.
// default: false
// -
// name: username
// in: query
// type: string
// description: Search for the given username.
// -
// name: display_name
// in: query
// type: string
// description: Search for the given display name.
// -
// name: by_domain
// in: query
// type: string
// description: Filter by the given domain.
// -
// name: email
// in: query
// type: string
// description: Lookup a user with this email.
// -
// name: ip
// in: query
// type: string
// description: Lookup users with this IP address.
// -
// name: staff
// in: query
// type: boolean
// description: Filter for staff accounts.
// default: false
// -
// 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.
// -
// name: min_id
// in: query
// type: string
// description: Returns results immediately newer than the item with this ID.
// -
// name: limit
// in: query
// type: integer
// description: Maximum number of results to return.
// default: 100
// maximum: 200
// minimum: 1
//
// security:
// - OAuth2 Bearer:
// - admin
//
// responses:
// '200':
// headers:
// Link:
// type: string
// description: Links to the next and previous queries.
// schema:
// type: array
// items:
// "$ref": "#/definitions/adminAccountInfo"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '403':
// description: forbidden
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
package admin
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
func (m *Module) AccountsGETV1Handler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if !*authed.User.Admin {
err := fmt.Errorf("user %s not an admin", authed.User.ID)
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 100, 200, 1)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
/* Translate to v2 `origin` query param */
local, errWithCode := apiutil.ParseLocal(c.Query(apiutil.LocalKey), false)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
remote, errWithCode := apiutil.ParseAdminRemote(c.Query(apiutil.AdminRemoteKey), false)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
if local && remote {
keys := []string{apiutil.LocalKey, apiutil.AdminRemoteKey}
err := fmt.Errorf("only one of %+v can be true", keys)
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
var origin string
if local {
origin = "local"
} else if remote {
origin = "remote"
}
/* Translate to v2 `status` query param */
active, errWithCode := apiutil.ParseAdminActive(c.Query(apiutil.AdminActiveKey), false)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
pending, errWithCode := apiutil.ParseAdminPending(c.Query(apiutil.AdminPendingKey), false)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
disabled, errWithCode := apiutil.ParseAdminDisabled(c.Query(apiutil.AdminDisabledKey), false)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
silenced, errWithCode := apiutil.ParseAdminSilenced(c.Query(apiutil.AdminSilencedKey), false)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
suspended, errWithCode := apiutil.ParseAdminSuspended(c.Query(apiutil.AdminSuspendedKey), false)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
// Ensure only one `status` query param set.
var status string
states := map[string]bool{
apiutil.AdminActiveKey: active,
apiutil.AdminPendingKey: pending,
apiutil.AdminDisabledKey: disabled,
apiutil.AdminSilencedKey: silenced,
apiutil.AdminSuspendedKey: suspended,
}
for k, v := range states {
if !v {
// False status,
// so irrelevant.
continue
}
if status != "" {
// Status was already set by another
// query param, this is an error.
keys := []string{
apiutil.AdminActiveKey,
apiutil.AdminPendingKey,
apiutil.AdminDisabledKey,
apiutil.AdminSilencedKey,
apiutil.AdminSuspendedKey,
}
err := fmt.Errorf("only one of %+v can be true", keys)
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
// Use this
// account status.
status = k
}
/* Translate to v2 `permissions` query param */
staff, errWithCode := apiutil.ParseAdminStaff(c.Query(apiutil.AdminStaffKey), false)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
var permissions string
if staff {
permissions = "staff"
}
// Parse out all optional params from the query.
params := &apimodel.AdminGetAccountsRequest{
Origin: origin,
Status: status,
Permissions: permissions,
RoleIDs: nil, // Can't do in V1.
InvitedBy: "", // Can't do in V1.
Username: c.Query(apiutil.UsernameKey),
DisplayName: c.Query(apiutil.AdminDisplayNameKey),
ByDomain: c.Query(apiutil.AdminByDomainKey),
Email: c.Query(apiutil.AdminEmailKey),
IP: c.Query(apiutil.AdminIPKey),
MaxID: apiutil.ParseMaxID(c.Query(apiutil.MaxIDKey), ""),
SinceID: apiutil.ParseSinceID(c.Query(apiutil.SinceIDKey), ""),
MinID: apiutil.ParseMinID(c.Query(apiutil.MinIDKey), ""),
Limit: limit,
APIVersion: 1,
}
resp, errWithCode := m.processor.Admin().AccountsGet(c.Request.Context(), params)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
if resp.LinkHeader != "" {
c.Header("Link", resp.LinkHeader)
}
apiutil.JSON(c, http.StatusOK, resp.Items)
}

View file

@ -0,0 +1,211 @@
// 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/>.
// AccountsGETHandlerV2 swagger:operation GET /api/v2/admin/accounts adminAccountsGetV2
//
// View + page through known accounts according to given filters.
//
// 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"
// ````
//
// ---
// tags:
// - admin
//
// produces:
// - application/json
//
// parameters:
// -
// name: origin
// in: query
// type: string
// description: Filter for `local` or `remote` accounts.
// -
// name: status
// in: query
// type: string
// description: Filter for `active`, `pending`, `disabled`, `silenced`, or `suspended` accounts.
// -
// name: permissions
// in: query
// type: string
// description: Filter for accounts with staff permissions (users that can manage reports).
// -
// name: role_ids[]
// in: query
// type: array
// items:
// type: string
// description: Filter for users with these roles.
// -
// name: invited_by
// in: query
// type: string
// description: Lookup users invited by the account with this ID.
// -
// name: username
// in: query
// type: string
// description: Search for the given username.
// -
// name: display_name
// in: query
// type: string
// description: Search for the given display name.
// -
// name: by_domain
// in: query
// type: string
// description: Filter by the given domain.
// -
// name: email
// in: query
// type: string
// description: Lookup a user with this email.
// -
// name: ip
// in: query
// type: string
// description: Lookup users with this IP address.
// -
// 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.
// -
// name: min_id
// in: query
// type: string
// description: Returns results immediately newer than the item with this ID.
// -
// name: limit
// in: query
// type: integer
// description: Maximum number of results to return.
// default: 100
// maximum: 200
// minimum: 1
//
// security:
// - OAuth2 Bearer:
// - admin
//
// responses:
// '200':
// headers:
// Link:
// type: string
// description: Links to the next and previous queries.
// schema:
// type: array
// items:
// "$ref": "#/definitions/adminAccountInfo"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '403':
// description: forbidden
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
package admin
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
func (m *Module) AccountsGETV2Handler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if !*authed.User.Admin {
err := fmt.Errorf("user %s not an admin", authed.User.ID)
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 100, 200, 1)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
// Parse out all optional params from the query.
params := &apimodel.AdminGetAccountsRequest{
Origin: c.Query(apiutil.AdminOriginKey),
Status: c.Query(apiutil.AdminStatusKey),
Permissions: c.Query(apiutil.AdminPermissionsKey),
RoleIDs: c.QueryArray(apiutil.AdminRoleIDsKey),
InvitedBy: c.Query(apiutil.AdminInvitedByKey),
Username: c.Query(apiutil.UsernameKey),
DisplayName: c.Query(apiutil.AdminDisplayNameKey),
ByDomain: c.Query(apiutil.AdminByDomainKey),
Email: c.Query(apiutil.AdminEmailKey),
IP: c.Query(apiutil.AdminIPKey),
MaxID: apiutil.ParseMaxID(c.Query(apiutil.MaxIDKey), ""),
SinceID: apiutil.ParseSinceID(c.Query(apiutil.SinceIDKey), ""),
MinID: apiutil.ParseMinID(c.Query(apiutil.MinIDKey), ""),
Limit: limit,
APIVersion: 2,
}
resp, errWithCode := m.processor.Admin().AccountsGet(c.Request.Context(), params)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
if resp.LinkHeader != "" {
c.Header("Link", resp.LinkHeader)
}
apiutil.JSON(c, http.StatusOK, resp.Items)
}

View file

@ -39,9 +39,12 @@ const (
HeaderAllowsPathWithID = HeaderAllowsPath + "/:" + IDKey
HeaderBlocksPath = BasePath + "/header_blocks"
HeaderBlocksPathWithID = HeaderBlocksPath + "/:" + IDKey
AccountsPath = BasePath + "/accounts"
AccountsPathWithID = AccountsPath + "/:" + IDKey
AccountsV1Path = BasePath + "/accounts"
AccountsV2Path = "/v2/admin/accounts"
AccountsPathWithID = AccountsV1Path + "/:" + IDKey
AccountsActionPath = AccountsPathWithID + "/action"
AccountsApprovePath = AccountsPathWithID + "/approve"
AccountsRejectPath = AccountsPathWithID + "/reject"
MediaCleanupPath = BasePath + "/media_cleanup"
MediaRefetchPath = BasePath + "/media_refetch"
ReportsPath = BasePath + "/reports"
@ -113,7 +116,12 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H
attachHandler(http.MethodPost, DomainKeysExpirePath, m.DomainKeysExpirePOSTHandler)
// accounts stuff
attachHandler(http.MethodGet, AccountsV1Path, m.AccountsGETV1Handler)
attachHandler(http.MethodGet, AccountsV2Path, m.AccountsGETV2Handler)
attachHandler(http.MethodGet, AccountsPathWithID, m.AccountGETHandler)
attachHandler(http.MethodPost, AccountsActionPath, m.AccountActionPOSTHandler)
attachHandler(http.MethodPost, AccountsApprovePath, m.AccountApprovePOSTHandler)
attachHandler(http.MethodPost, AccountsRejectPath, m.AccountRejectPOSTHandler)
// media stuff
attachHandler(http.MethodPost, MediaCleanupPath, m.MediaCleanupPOSTHandler)

View file

@ -229,3 +229,63 @@ type DebugAPUrlResponse struct {
// may be an error, may be both!
ResponseBody string `json:"response_body"`
}
// AdminGetAccountsRequest models a request
// to get an admin view of one or more
// accounts using given parameters.
//
// swagger:ignore
type AdminGetAccountsRequest struct {
// Filter for `local` or `remote` accounts.
Origin string
// Filter for `active`, `pending`, `disabled`,
// `silenced`, or `suspended` accounts.
Status string
// Filter for accounts with staff perms
// (users that can manage reports).
Permissions string
// Filter for users with these roles.
RoleIDs []string
// Lookup users invited by the account with this ID.
InvitedBy string
// Search for the given username.
Username string
// Search for the given display name.
DisplayName string
// Filter by the given domain.
ByDomain string
// Lookup a user with this email.
Email string
// Lookup users with this IP address.
IP string
// All results returned will be
// older than the item with this ID.
MaxID string
// All results returned will be
// newer than the item with this ID.
SinceID string
// Returns results immediately newer
// than the item with this ID.
MinID string
// Maximum number of results to return.
Limit int
// API version to use for this request (1 or 2).
// Set internally, not by callers.
APIVersion int
}
// AdminAccountRejectRequest models a
// request to deny a new account sign-up.
//
// swagger:ignore
type AdminAccountRejectRequest struct {
// Comment to leave on why the account was denied.
// The comment will be visible to admins only.
PrivateComment string `form:"private_comment" json:"private_comment"`
// Message to include in email to applicant.
// Will be included only if send_email is true.
Message string `form:"message" json:"message"`
// Send an email to the applicant informing
// them that their sign-up has been rejected.
SendEmail bool `form:"send_email" json:"send_email"`
}

View file

@ -34,12 +34,13 @@ const (
/* Common keys */
IDKey = "id"
LimitKey = "limit"
LocalKey = "local"
MaxIDKey = "max_id"
SinceIDKey = "since_id"
MinIDKey = "min_id"
IDKey = "id"
LimitKey = "limit"
LocalKey = "local"
MaxIDKey = "max_id"
SinceIDKey = "since_id"
MinIDKey = "min_id"
UsernameKey = "username"
/* AP endpoint keys */
@ -61,19 +62,62 @@ const (
/* Web endpoint keys */
WebUsernameKey = "username"
WebStatusIDKey = "status"
/* Domain permission keys */
DomainPermissionExportKey = "export"
DomainPermissionImportKey = "import"
/* Admin query keys */
AdminRemoteKey = "remote"
AdminActiveKey = "active"
AdminPendingKey = "pending"
AdminDisabledKey = "disabled"
AdminSilencedKey = "silenced"
AdminSuspendedKey = "suspended"
AdminSensitizedKey = "sensitized"
AdminDisplayNameKey = "display_name"
AdminByDomainKey = "by_domain"
AdminEmailKey = "email"
AdminIPKey = "ip"
AdminStaffKey = "staff"
AdminOriginKey = "origin"
AdminStatusKey = "status"
AdminPermissionsKey = "permissions"
AdminRoleIDsKey = "role_ids[]"
AdminInvitedByKey = "invited_by"
)
/*
Parse functions for *OPTIONAL* parameters with default values.
*/
func ParseMaxID(value string, defaultValue string) string {
if value == "" {
return defaultValue
}
return value
}
func ParseSinceID(value string, defaultValue string) string {
if value == "" {
return defaultValue
}
return value
}
func ParseMinID(value string, defaultValue string) string {
if value == "" {
return defaultValue
}
return value
}
func ParseLimit(value string, defaultValue int, max, min int) (int, gtserror.WithCode) {
i, err := parseInt(value, defaultValue, max, min, LimitKey)
if err != nil {
@ -87,14 +131,6 @@ func ParseLocal(value string, defaultValue bool) (bool, gtserror.WithCode) {
return parseBool(value, defaultValue, LocalKey)
}
func ParseMaxID(value string, defaultValue string) string {
if value == "" {
return defaultValue
}
return value
}
func ParseSearchExcludeUnreviewed(value string, defaultValue bool) (bool, gtserror.WithCode) {
return parseBool(value, defaultValue, SearchExcludeUnreviewedKey)
}
@ -123,6 +159,34 @@ func ParseOnlyOtherAccounts(value string, defaultValue bool) (bool, gtserror.Wit
return parseBool(value, defaultValue, OnlyOtherAccountsKey)
}
func ParseAdminRemote(value string, defaultValue bool) (bool, gtserror.WithCode) {
return parseBool(value, defaultValue, AdminRemoteKey)
}
func ParseAdminActive(value string, defaultValue bool) (bool, gtserror.WithCode) {
return parseBool(value, defaultValue, AdminActiveKey)
}
func ParseAdminPending(value string, defaultValue bool) (bool, gtserror.WithCode) {
return parseBool(value, defaultValue, AdminPendingKey)
}
func ParseAdminDisabled(value string, defaultValue bool) (bool, gtserror.WithCode) {
return parseBool(value, defaultValue, AdminDisabledKey)
}
func ParseAdminSilenced(value string, defaultValue bool) (bool, gtserror.WithCode) {
return parseBool(value, defaultValue, AdminSilencedKey)
}
func ParseAdminSuspended(value string, defaultValue bool) (bool, gtserror.WithCode) {
return parseBool(value, defaultValue, AdminSuspendedKey)
}
func ParseAdminStaff(value string, defaultValue bool) (bool, gtserror.WithCode) {
return parseBool(value, defaultValue, AdminStaffKey)
}
/*
Parse functions for *REQUIRED* parameters.
*/
@ -187,8 +251,8 @@ func ParseTagName(value string) (string, gtserror.WithCode) {
return value, nil
}
func ParseWebUsername(value string) (string, gtserror.WithCode) {
key := WebUsernameKey
func ParseUsername(value string) (string, gtserror.WithCode) {
key := UsernameKey
if value == "" {
return "", requiredError(key)

View file

@ -19,6 +19,7 @@ package db
import (
"context"
"net"
"time"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
@ -56,6 +57,25 @@ type Account interface {
// GetAccountByFollowersURI returns one account with the given followers_uri, or an error if something goes wrong.
GetAccountByFollowersURI(ctx context.Context, uri string) (*gtsmodel.Account, error)
// GetAccounts returns accounts
// with the given parameters.
GetAccounts(
ctx context.Context,
origin string,
status string,
mods bool,
invitedBy string,
username string,
displayName string,
domain string,
email string,
ip net.IP,
maxID string,
sinceID string,
minID string,
limit int,
) ([]*gtsmodel.Account, error)
// PopulateAccount ensures that all sub-models of an account are populated (e.g. avatar, header etc).
PopulateAccount(ctx context.Context, account *gtsmodel.Account) error

View file

@ -20,6 +20,8 @@ package bundb
import (
"context"
"errors"
"fmt"
"net"
"strings"
"time"
@ -222,6 +224,218 @@ func (a *accountDB) GetInstanceAccount(ctx context.Context, domain string) (*gts
return a.GetAccountByUsernameDomain(ctx, username, domain)
}
func (a *accountDB) GetAccounts(
ctx context.Context,
origin string,
status string,
mods bool,
invitedBy string,
username string,
displayName string,
domain string,
email string,
ip net.IP,
maxID string,
sinceID string,
minID string,
limit int,
) ([]*gtsmodel.Account, error) {
// Ensure reasonable
if limit < 0 {
limit = 0
}
// Make educated guess for slice size
var (
accountIDs = make([]string, 0, limit)
accountIDIn []string
useAccountIDIn bool
frontToBack = true
)
// We need users for this query.
users, err := a.state.DB.GetAllUsers(gtscontext.SetBarebones(ctx))
if err != nil {
return nil, fmt.Errorf("error getting users: %w", err)
}
q := a.db.
NewSelect().
TableExpr("? AS ?", bun.Ident("accounts"), bun.Ident("account")).
// 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,
)
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)
}
if sinceID != "" {
// Return only accounts NEWER
// than account with sinceID.
sinceIDAcct, err := a.GetAccountByID(
gtscontext.SetBarebones(ctx),
sinceID,
)
if err != nil {
return nil, fmt.Errorf("error getting sinceID account %s: %w", sinceID, err)
}
q = q.Where("? > ?", bun.Ident("account.created_at"), sinceIDAcct.CreatedAt)
}
if minID != "" {
// Return only accounts NEWER
// than account with minID.
minIDAcct, err := a.GetAccountByID(
gtscontext.SetBarebones(ctx),
sinceID,
)
if err != nil {
return nil, fmt.Errorf("error getting minID account %s: %w", minID, err)
}
q = q.Where("? > ?", bun.Ident("account.created_at"), minIDAcct.CreatedAt)
// Paging up.
frontToBack = false
}
if origin == "local" {
// Get only local accounts.
q = q.Where("? IS NULL", bun.Ident("account.domain"))
} else if origin == "remote" {
// Get only remote accounts.
q = q.Where("? IS NOT NULL", bun.Ident("account.domain"))
}
switch status {
case "active":
// Get only enabled accounts.
for _, user := range users {
if !*user.Disabled {
accountIDIn = append(accountIDIn, user.AccountID)
}
}
useAccountIDIn = true
case "pending":
// Get only unapproved accounts.
for _, user := range users {
if !*user.Approved {
accountIDIn = append(accountIDIn, user.AccountID)
}
}
useAccountIDIn = true
case "disabled":
// Get only disabled accounts.
for _, user := range users {
if *user.Disabled {
accountIDIn = append(accountIDIn, user.AccountID)
}
}
useAccountIDIn = true
case "silenced":
// Get only silenced accounts.
q = q.Where("? IS NOT NULL", bun.Ident("account.silenced_at"))
case "suspended":
// Get only suspended accounts.
q = q.Where("? IS NOT NULL", bun.Ident("account.suspended_at"))
}
if mods {
// Get only mod accounts.
for _, user := range users {
if *user.Moderator || *user.Admin {
accountIDIn = append(accountIDIn, user.AccountID)
}
}
useAccountIDIn = true
}
// TODO: invitedBy
if username != "" {
q = q.Where("? = ?", bun.Ident("account.username"), username)
}
if displayName != "" {
q = q.Where("? = ?", bun.Ident("account.display_name"), displayName)
}
if domain != "" {
q = q.Where("? = ?", bun.Ident("account.domain"), domain)
}
if email != "" {
for _, user := range users {
if user.Email == email || user.UnconfirmedEmail == email {
accountIDIn = append(accountIDIn, user.AccountID)
}
}
useAccountIDIn = true
}
if ip != nil {
for _, user := range users {
if user.SignUpIP.String() == ip.String() {
accountIDIn = append(accountIDIn, user.AccountID)
}
}
useAccountIDIn = true
}
if useAccountIDIn {
q = q.Where("? IN (?)", bun.Ident("account.id"), bun.In(accountIDIn))
}
if limit > 0 {
// limit amount of statuses returned
q = q.Limit(limit)
}
if frontToBack {
// Page down.
q = q.Order("account.created_at DESC")
} else {
// Page up.
q = q.Order("account.created_at ASC")
}
if err := q.Scan(ctx, &accountIDs); err != nil {
return nil, err
}
if len(accountIDs) == 0 {
return nil, nil
}
// If we're paging up, we still want accounts
// to be sorted by createdAt desc, so reverse ids slice.
// https://zchee.github.io/golang-wiki/SliceTricks/#reversing
if !frontToBack {
for l, r := 0, len(accountIDs)-1; l < r; l, r = l+1, r-1 {
accountIDs[l], accountIDs[r] = accountIDs[r], accountIDs[l]
}
}
// Return account IDs loaded from cache + db.
return a.state.DB.GetAccountsByIDs(ctx, accountIDs)
}
func (a *accountDB) getAccount(ctx context.Context, lookup string, dbQuery func(*gtsmodel.Account) error, keyParts ...any) (*gtsmodel.Account, error) {
// Fetch account from database cache with loader callback
account, err := a.state.Caches.GTS.Account.LoadOne(lookup, func() (*gtsmodel.Account, error) {

View file

@ -23,6 +23,7 @@ import (
"crypto/rsa"
"errors"
"fmt"
"net"
"reflect"
"strings"
"testing"
@ -491,6 +492,170 @@ func (suite *AccountTestSuite) TestPopulateAccountWithUnknownMovedToURI() {
suite.NoError(err)
}
func (suite *AccountTestSuite) TestGetAccountsAll() {
var (
ctx = context.Background()
origin = ""
status = ""
mods = false
invitedBy = ""
username = ""
displayName = ""
domain = ""
email = ""
ip net.IP = nil
maxID = ""
sinceID = ""
minID = ""
limit = 100
)
accounts, err := suite.db.GetAccounts(
ctx,
origin,
status,
mods,
invitedBy,
username,
displayName,
domain,
email,
ip,
maxID,
sinceID,
minID,
limit,
)
if err != nil {
suite.FailNow(err.Error())
}
suite.Len(accounts, 9)
}
func (suite *AccountTestSuite) TestGetAccountsModsOnly() {
var (
ctx = context.Background()
origin = ""
status = ""
mods = true
invitedBy = ""
username = ""
displayName = ""
domain = ""
email = ""
ip net.IP = nil
maxID = ""
sinceID = ""
minID = ""
limit = 100
)
accounts, err := suite.db.GetAccounts(
ctx,
origin,
status,
mods,
invitedBy,
username,
displayName,
domain,
email,
ip,
maxID,
sinceID,
minID,
limit,
)
if err != nil {
suite.FailNow(err.Error())
}
suite.Len(accounts, 1)
}
func (suite *AccountTestSuite) TestGetAccountsLocalWithEmail() {
var (
ctx = context.Background()
origin = "local"
status = ""
mods = false
invitedBy = ""
username = ""
displayName = ""
domain = ""
email = "tortle.dude@example.org"
ip net.IP = nil
maxID = ""
sinceID = ""
minID = ""
limit = 100
)
accounts, err := suite.db.GetAccounts(
ctx,
origin,
status,
mods,
invitedBy,
username,
displayName,
domain,
email,
ip,
maxID,
sinceID,
minID,
limit,
)
if err != nil {
suite.FailNow(err.Error())
}
suite.Len(accounts, 1)
}
func (suite *AccountTestSuite) TestGetPendingAccounts() {
var (
ctx = context.Background()
origin = ""
status = "pending"
mods = false
invitedBy = ""
username = ""
displayName = ""
domain = ""
email = ""
ip net.IP = nil
maxID = ""
sinceID = ""
minID = ""
limit = 100
)
accounts, err := suite.db.GetAccounts(
ctx,
origin,
status,
mods,
invitedBy,
username,
displayName,
domain,
email,
ip,
maxID,
sinceID,
minID,
limit,
)
if err != nil {
suite.FailNow(err.Error())
}
suite.Len(accounts, 1)
}
func TestAccountTestSuite(t *testing.T) {
suite.Run(t, new(AccountTestSuite))
}

View file

@ -230,3 +230,23 @@ func (u *userDB) DeleteUserByID(ctx context.Context, userID string) error {
Exec(ctx)
return err
}
func (u *userDB) PutDeniedUser(ctx context.Context, deniedUser *gtsmodel.DeniedUser) error {
_, err := u.db.NewInsert().
Model(deniedUser).
Exec(ctx)
return err
}
func (u *userDB) GetDeniedUserByID(ctx context.Context, id string) (*gtsmodel.DeniedUser, error) {
deniedUser := new(gtsmodel.DeniedUser)
if err := u.db.
NewSelect().
Model(deniedUser).
Where("? = ?", bun.Ident("denied_user.id"), id).
Scan(ctx); err != nil {
return nil, err
}
return deniedUser, nil
}

View file

@ -54,4 +54,10 @@ type User interface {
// DeleteUserByID deletes one user by its ID.
DeleteUserByID(ctx context.Context, userID string) error
// PutDeniedUser inserts the given deniedUser into the db.
PutDeniedUser(ctx context.Context, deniedUser *gtsmodel.DeniedUser) error
// GetDeniedUserByID returns one denied user with the given ID.
GetDeniedUserByID(ctx context.Context, id string) (*gtsmodel.DeniedUser, error)
}

View file

@ -0,0 +1,67 @@
// 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 (
"context"
"testing"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
type AdminApproveTestSuite struct {
AdminStandardTestSuite
}
func (suite *AdminApproveTestSuite) TestApprove() {
var (
ctx = context.Background()
adminAcct = suite.testAccounts["admin_account"]
targetAcct = suite.testAccounts["unconfirmed_account"]
targetUser = new(gtsmodel.User)
)
// Copy user since we're modifying it.
*targetUser = *suite.testUsers["unconfirmed_account"]
// Approve the sign-up.
acct, errWithCode := suite.adminProcessor.AccountApprove(
ctx,
adminAcct,
targetAcct.ID,
)
if errWithCode != nil {
suite.FailNow(errWithCode.Error())
}
// Account should be approved.
suite.NotNil(acct)
suite.True(acct.Approved)
// Check DB entry too.
dbUser, err := suite.state.DB.GetUserByID(ctx, targetUser.ID)
if err != nil {
suite.FailNow(err.Error())
}
suite.True(*dbUser.Approved)
}
func TestAdminApproveTestSuite(t *testing.T) {
suite.Run(t, new(AdminApproveTestSuite))
}

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 admin
import (
"context"
"errors"
"fmt"
"github.com/superseriousbusiness/gotosocial/internal/ap"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/messages"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
func (p *Processor) AccountApprove(
ctx context.Context,
adminAcct *gtsmodel.Account,
accountID string,
) (*apimodel.AdminAccountInfo, gtserror.WithCode) {
user, err := p.state.DB.GetUserByAccountID(ctx, accountID)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error getting user for account id %s: %w", accountID, err)
return nil, gtserror.NewErrorInternalError(err)
}
if user == nil {
err := fmt.Errorf("user for account %s not found", accountID)
return nil, gtserror.NewErrorNotFound(err, err.Error())
}
if !*user.Approved {
// Mark user as approved.
user.Approved = util.Ptr(true)
if err := p.state.DB.UpdateUser(ctx, user, "approved"); err != nil {
err := gtserror.Newf("db error updating user %s: %w", user.ID, err)
return nil, gtserror.NewErrorInternalError(err)
}
// Process approval side effects asynschronously.
p.state.Workers.EnqueueClientAPI(ctx, messages.FromClientAPI{
APObjectType: ap.ActorPerson,
APActivityType: ap.ActivityAccept,
GTSModel: user,
OriginAccount: adminAcct,
TargetAccount: user.Account,
})
}
apiAccount, err := p.converter.AccountToAdminAPIAccount(ctx, user.Account)
if err != nil {
err := gtserror.Newf("error converting account %s to admin api model: %w", accountID, err)
return nil, gtserror.NewErrorInternalError(err)
}
return apiAccount, nil
}

View file

@ -0,0 +1,49 @@
// 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
import (
"context"
"errors"
"fmt"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
)
func (p *Processor) AccountGet(ctx context.Context, accountID string) (*apimodel.AdminAccountInfo, gtserror.WithCode) {
account, err := p.state.DB.GetAccountByID(ctx, accountID)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error getting account %s: %w", accountID, err)
return nil, gtserror.NewErrorInternalError(err)
}
if account == nil {
err := fmt.Errorf("account %s not found", accountID)
return nil, gtserror.NewErrorNotFound(err, err.Error())
}
apiAccount, err := p.converter.AccountToAdminAPIAccount(ctx, account)
if err != nil {
err := gtserror.Newf("error converting account %s to admin api model: %w", accountID, err)
return nil, gtserror.NewErrorInternalError(err)
}
return apiAccount, nil
}

View file

@ -0,0 +1,123 @@
// 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
import (
"context"
"errors"
"fmt"
"github.com/superseriousbusiness/gotosocial/internal/ap"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/messages"
)
func (p *Processor) AccountReject(
ctx context.Context,
adminAcct *gtsmodel.Account,
accountID string,
privateComment string,
sendEmail bool,
message string,
) (*apimodel.AdminAccountInfo, gtserror.WithCode) {
user, err := p.state.DB.GetUserByAccountID(ctx, accountID)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error getting user for account id %s: %w", accountID, err)
return nil, gtserror.NewErrorInternalError(err)
}
if user == nil {
err := fmt.Errorf("user for account %s not found", accountID)
return nil, gtserror.NewErrorNotFound(err, err.Error())
}
// Get a lock on the account URI,
// since we're going to be deleting
// it and its associated user.
unlock := p.state.ClientLocks.Lock(user.Account.URI)
defer unlock()
// Can't reject an account with a
// user that's already been approved.
if *user.Approved {
err := fmt.Errorf("account %s has already been approved", accountID)
return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error())
}
// Convert to API account *before* doing the
// rejection, since the rejection will cause
// the user and account to be removed.
apiAccount, err := p.converter.AccountToAdminAPIAccount(ctx, user.Account)
if err != nil {
err := gtserror.Newf("error converting account %s to admin api model: %w", accountID, err)
return nil, gtserror.NewErrorInternalError(err)
}
// Remove the account.
if err := p.state.DB.DeleteAccount(ctx, accountID); err != nil {
err := gtserror.Newf("db error deleting account %s: %w", accountID, err)
return nil, gtserror.NewErrorInternalError(err)
}
// Remove the user.
if err := p.state.DB.DeleteUserByID(ctx, user.ID); err != nil {
err := gtserror.Newf("db error deleting user %s: %w", user.ID, err)
return nil, gtserror.NewErrorInternalError(err)
}
var email string
if user.Email != "" {
email = user.Email
} else {
email = user.UnconfirmedEmail
}
// Create and store a denied user entry.
deniedUser := &gtsmodel.DeniedUser{
ID: user.ID,
Email: email,
Username: user.Account.Username,
SignUpIP: user.SignUpIP,
InviteID: user.InviteID,
Locale: user.Locale,
CreatedByApplicationID: user.CreatedByApplicationID,
SignUpReason: user.Reason,
PrivateComment: privateComment,
SendEmail: &sendEmail,
Message: message,
}
if err := p.state.DB.PutDeniedUser(ctx, deniedUser); err != nil {
err := gtserror.Newf("db error putting denied user %s: %w", deniedUser.ID, err)
return nil, gtserror.NewErrorInternalError(err)
}
// Process rejection side effects asynschronously.
p.state.Workers.EnqueueClientAPI(ctx, messages.FromClientAPI{
APObjectType: ap.ActorPerson,
APActivityType: ap.ActivityReject,
GTSModel: deniedUser,
OriginAccount: adminAcct,
TargetAccount: user.Account,
})
return apiAccount, nil
}

View file

@ -0,0 +1,133 @@
// 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 (
"context"
"testing"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/db"
)
type AdminRejectTestSuite struct {
AdminStandardTestSuite
}
func (suite *AdminRejectTestSuite) TestReject() {
var (
ctx = context.Background()
adminAcct = suite.testAccounts["admin_account"]
targetAcct = suite.testAccounts["unconfirmed_account"]
targetUser = suite.testUsers["unconfirmed_account"]
privateComment = "It's a no from me chief."
sendEmail = true
message = "Too stinky."
)
acct, errWithCode := suite.adminProcessor.AccountReject(
ctx,
adminAcct,
targetAcct.ID,
privateComment,
sendEmail,
message,
)
if errWithCode != nil {
suite.FailNow(errWithCode.Error())
}
suite.NotNil(acct)
// Should be a denied user entry now.
deniedUser, err := suite.state.DB.GetDeniedUserByID(ctx, targetUser.ID)
if err != nil {
suite.FailNow(err.Error())
}
// Ensure fields as expected.
suite.Equal(targetUser.ID, deniedUser.ID)
suite.Equal(targetUser.UnconfirmedEmail, deniedUser.Email)
suite.Equal(targetAcct.Username, deniedUser.Username)
suite.Equal(targetUser.SignUpIP, deniedUser.SignUpIP)
suite.Equal(targetUser.InviteID, deniedUser.InviteID)
suite.Equal(targetUser.Locale, deniedUser.Locale)
suite.Equal(targetUser.CreatedByApplicationID, deniedUser.CreatedByApplicationID)
suite.Equal(targetUser.Reason, deniedUser.SignUpReason)
suite.Equal(privateComment, deniedUser.PrivateComment)
suite.Equal(sendEmail, *deniedUser.SendEmail)
suite.Equal(message, deniedUser.Message)
// Should be no user entry for
// this denied request now.
_, err = suite.state.DB.GetUserByID(ctx, targetUser.ID)
suite.ErrorIs(db.ErrNoEntries, err)
// Should be no account entry for
// this denied request now.
_, err = suite.state.DB.GetAccountByID(ctx, targetAcct.ID)
suite.ErrorIs(db.ErrNoEntries, err)
}
func (suite *AdminRejectTestSuite) TestRejectRemote() {
var (
ctx = context.Background()
adminAcct = suite.testAccounts["admin_account"]
targetAcct = suite.testAccounts["remote_account_1"]
privateComment = "It's a no from me chief."
sendEmail = true
message = "Too stinky."
)
// Try to reject a remote account.
_, err := suite.adminProcessor.AccountReject(
ctx,
adminAcct,
targetAcct.ID,
privateComment,
sendEmail,
message,
)
suite.EqualError(err, "user for account 01F8MH5ZK5VRH73AKHQM6Y9VNX not found")
}
func (suite *AdminRejectTestSuite) TestRejectApproved() {
var (
ctx = context.Background()
adminAcct = suite.testAccounts["admin_account"]
targetAcct = suite.testAccounts["local_account_1"]
privateComment = "It's a no from me chief."
sendEmail = true
message = "Too stinky."
)
// Try to reject an already-approved account.
_, err := suite.adminProcessor.AccountReject(
ctx,
adminAcct,
targetAcct.ID,
privateComment,
sendEmail,
message,
)
suite.EqualError(err, "account 01F8MH1H7YV1Z7D2C8K2730QBF has already been approved")
}
func TestAdminRejectTestSuite(t *testing.T) {
suite.Run(t, new(AdminRejectTestSuite))
}

View file

@ -0,0 +1,310 @@
// 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
import (
"context"
"errors"
"fmt"
"net"
"slices"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
func (p *Processor) AccountsGet(
ctx context.Context,
request *apimodel.AdminGetAccountsRequest,
) (*apimodel.PageableResponse, gtserror.WithCode) {
// Validate "origin".
if v := request.Origin; v != "" {
valid := []string{"local", "remote"}
if !slices.Contains(valid, v) {
err := fmt.Errorf("origin %s not recognized; valid choices are %+v", v, valid)
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
}
// Validate "status".
if v := request.Status; v != "" {
valid := []string{"active", "pending", "disabled", "silenced", "suspended"}
if !slices.Contains(valid, v) {
err := fmt.Errorf("status %s not recognized; valid choices are %+v", v, valid)
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
}
// Validate "permissions".
if v := request.Permissions; v != "" {
valid := []string{"staff"}
if !slices.Contains(valid, v) {
err := fmt.Errorf("permissions %s not recognized; valid choices are %+v", v, valid)
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
}
var ip net.IP
if v := request.IP; v != "" {
ip = net.ParseIP(v)
if ip == nil {
err := fmt.Errorf("ip %s not a valid IP address", v)
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
}
// Get accounts with the given params.
accounts, err := p.state.DB.GetAccounts(
ctx,
request.Origin,
request.Status,
func() bool { return request.Permissions == "staff" }(),
request.InvitedBy,
request.Username,
request.DisplayName,
request.ByDomain,
request.Email,
ip,
request.MaxID,
request.SinceID,
request.MinID,
request.Limit,
)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err = gtserror.Newf("db error getting accounts: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
count := len(accounts)
if count == 0 {
return util.EmptyPageableResponse(), nil
}
nextMax := accounts[count-1].ID
prevMin := accounts[0].ID
items := make([]interface{}, 0, count)
for _, account := range accounts {
apiAccount, err := p.converter.AccountToAdminAPIAccount(ctx, account)
if err != nil {
log.Errorf(ctx, "error converting to api account: %v", err)
continue
}
items = append(items, apiAccount)
}
// Return packaging + paging appropriate for
// the API version used to call this function.
switch request.APIVersion {
case 1:
return packageAccountsV1(items, nextMax, prevMin, request)
case 2:
return packageAccountsV2(items, nextMax, prevMin, request)
default:
log.Panic(ctx, "api version was neither 1 nor 2")
return nil, nil
}
}
func packageAccountsV1(
items []interface{},
nextMax string,
prevMin string,
request *apimodel.AdminGetAccountsRequest,
) (*apimodel.PageableResponse, gtserror.WithCode) {
extraQueryParams := []string{}
// Translate origin to v1.
if v := request.Origin; v != "" {
var k string
if v == "local" {
k = apiutil.LocalKey
} else {
k = apiutil.AdminRemoteKey
}
extraQueryParams = append(
extraQueryParams,
k+"=true",
)
}
// Translate status to v1.
if v := request.Status; v != "" {
var k string
switch v {
case "active":
k = apiutil.AdminActiveKey
case "pending":
k = apiutil.AdminPendingKey
case "disabled":
k = apiutil.AdminDisabledKey
case "silenced":
k = apiutil.AdminSilencedKey
case "suspended":
k = apiutil.AdminSuspendedKey
}
extraQueryParams = append(
extraQueryParams,
k+"=true",
)
}
if v := request.Username; v != "" {
extraQueryParams = append(
extraQueryParams,
apiutil.UsernameKey+"="+v,
)
}
if v := request.DisplayName; v != "" {
extraQueryParams = append(
extraQueryParams,
apiutil.AdminDisplayNameKey+"="+v,
)
}
if v := request.ByDomain; v != "" {
extraQueryParams = append(
extraQueryParams,
apiutil.AdminByDomainKey+"="+v,
)
}
if v := request.Email; v != "" {
extraQueryParams = append(
extraQueryParams,
apiutil.AdminEmailKey+"="+v,
)
}
if v := request.IP; v != "" {
extraQueryParams = append(
extraQueryParams,
apiutil.AdminIPKey+"="+v,
)
}
// Translate permissions to v1.
if v := request.Permissions; v != "" {
extraQueryParams = append(
extraQueryParams,
apiutil.AdminStaffKey+"=true",
)
}
return util.PackagePageableResponse(util.PageableResponseParams{
Items: items,
Path: "/api/v1/admin/accounts",
NextMaxIDValue: nextMax,
PrevMinIDValue: prevMin,
Limit: request.Limit,
ExtraQueryParams: extraQueryParams,
})
}
func packageAccountsV2(
items []interface{},
nextMax string,
prevMin string,
request *apimodel.AdminGetAccountsRequest,
) (*apimodel.PageableResponse, gtserror.WithCode) {
extraQueryParams := []string{}
if v := request.Origin; v != "" {
extraQueryParams = append(
extraQueryParams,
apiutil.AdminOriginKey+"="+v,
)
}
if v := request.Status; v != "" {
extraQueryParams = append(
extraQueryParams,
apiutil.AdminStatusKey+"="+v,
)
}
if v := request.Permissions; v != "" {
extraQueryParams = append(
extraQueryParams,
apiutil.AdminPermissionsKey+"="+v,
)
}
if v := request.InvitedBy; v != "" {
extraQueryParams = append(
extraQueryParams,
apiutil.AdminInvitedByKey+"="+v,
)
}
if v := request.Username; v != "" {
extraQueryParams = append(
extraQueryParams,
apiutil.UsernameKey+"="+v,
)
}
if v := request.DisplayName; v != "" {
extraQueryParams = append(
extraQueryParams,
apiutil.AdminDisplayNameKey+"="+v,
)
}
if v := request.ByDomain; v != "" {
extraQueryParams = append(
extraQueryParams,
apiutil.AdminByDomainKey+"="+v,
)
}
if v := request.Email; v != "" {
extraQueryParams = append(
extraQueryParams,
apiutil.AdminEmailKey+"="+v,
)
}
if v := request.IP; v != "" {
extraQueryParams = append(
extraQueryParams,
apiutil.AdminIPKey+"="+v,
)
}
return util.PackagePageableResponse(util.PageableResponseParams{
Items: items,
Path: "/api/v2/admin/accounts",
NextMaxIDValue: nextMax,
PrevMinIDValue: prevMin,
Limit: request.Limit,
ExtraQueryParams: extraQueryParams,
})
}

View file

@ -34,7 +34,7 @@ func (m *Module) customCSSGETHandler(c *gin.Context) {
return
}
targetUsername, errWithCode := apiutil.ParseWebUsername(c.Param(apiutil.WebUsernameKey))
targetUsername, errWithCode := apiutil.ParseUsername(c.Param(apiutil.UsernameKey))
if errWithCode != nil {
apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return

View file

@ -49,7 +49,7 @@ func (m *Module) profileGETHandler(c *gin.Context) {
}
// Parse account targetUsername from the URL.
targetUsername, errWithCode := apiutil.ParseWebUsername(c.Param(apiutil.WebUsernameKey))
targetUsername, errWithCode := apiutil.ParseUsername(c.Param(apiutil.UsernameKey))
if errWithCode != nil {
apiutil.WebErrorHandler(c, errWithCode, instanceGet)
return

View file

@ -38,7 +38,7 @@ func (m *Module) rssFeedGETHandler(c *gin.Context) {
}
// Fetch + normalize username from URL.
username, errWithCode := apiutil.ParseWebUsername(c.Param(apiutil.WebUsernameKey))
username, errWithCode := apiutil.ParseUsername(c.Param(apiutil.UsernameKey))
if errWithCode != nil {
apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return

View file

@ -50,7 +50,7 @@ func (m *Module) threadGETHandler(c *gin.Context) {
}
// Parse account targetUsername and status ID from the URL.
targetUsername, errWithCode := apiutil.ParseWebUsername(c.Param(apiutil.WebUsernameKey))
targetUsername, errWithCode := apiutil.ParseUsername(c.Param(apiutil.UsernameKey))
if errWithCode != nil {
apiutil.WebErrorHandler(c, errWithCode, instanceGet)
return