diff options
Diffstat (limited to 'internal')
30 files changed, 2454 insertions, 28 deletions
diff --git a/internal/api/client/admin/accountapprove.go b/internal/api/client/admin/accountapprove.go new file mode 100644 index 000000000..ff6474adb --- /dev/null +++ b/internal/api/client/admin/accountapprove.go @@ -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) +} diff --git a/internal/api/client/admin/accountget.go b/internal/api/client/admin/accountget.go new file mode 100644 index 000000000..3a656fecc --- /dev/null +++ b/internal/api/client/admin/accountget.go @@ -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) +} diff --git a/internal/api/client/admin/accountreject.go b/internal/api/client/admin/accountreject.go new file mode 100644 index 000000000..1e909b508 --- /dev/null +++ b/internal/api/client/admin/accountreject.go @@ -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) +} diff --git a/internal/api/client/admin/accountsgetv1.go b/internal/api/client/admin/accountsgetv1.go new file mode 100644 index 000000000..604d74992 --- /dev/null +++ b/internal/api/client/admin/accountsgetv1.go @@ -0,0 +1,348 @@ +// 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" + "github.com/superseriousbusiness/gotosocial/internal/paging" +) + +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 + } + + page, errWithCode := paging.ParseIDPage(c, 1, 200, 100) + 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), + APIVersion: 1, + } + + resp, errWithCode := m.processor.Admin().AccountsGet( + c.Request.Context(), + params, + page, + ) + 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) +} diff --git a/internal/api/client/admin/accountsgetv2.go b/internal/api/client/admin/accountsgetv2.go new file mode 100644 index 000000000..ca32b9e7f --- /dev/null +++ b/internal/api/client/admin/accountsgetv2.go @@ -0,0 +1,212 @@ +// 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" + "github.com/superseriousbusiness/gotosocial/internal/paging" +) + +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 + } + + page, errWithCode := paging.ParseIDPage(c, 1, 200, 100) + 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), + APIVersion: 2, + } + + resp, errWithCode := m.processor.Admin().AccountsGet( + c.Request.Context(), + params, + page, + ) + 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) +} diff --git a/internal/api/client/admin/admin.go b/internal/api/client/admin/admin.go index f247d7ce9..e898bca46 100644 --- a/internal/api/client/admin/admin.go +++ b/internal/api/client/admin/admin.go @@ -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) diff --git a/internal/api/model/admin.go b/internal/api/model/admin.go index ca84ffd88..637ab0ed7 100644 --- a/internal/api/model/admin.go +++ b/internal/api/model/admin.go @@ -229,3 +229,52 @@ 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 + // 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"` +} diff --git a/internal/api/util/parsequery.go b/internal/api/util/parsequery.go index da6320b67..54cb4c466 100644 --- a/internal/api/util/parsequery.go +++ b/internal/api/util/parsequery.go @@ -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,33 +62,55 @@ 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 ParseLimit(value string, defaultValue int, max, min int) (int, gtserror.WithCode) { - i, err := parseInt(value, defaultValue, max, min, LimitKey) - if err != nil { - return 0, err +func ParseMaxID(value string, defaultValue string) string { + if value == "" { + return defaultValue } - return i, nil + return value } -func ParseLocal(value string, defaultValue bool) (bool, gtserror.WithCode) { - return parseBool(value, defaultValue, LocalKey) +func ParseSinceID(value string, defaultValue string) string { + if value == "" { + return defaultValue + } + + return value } -func ParseMaxID(value string, defaultValue string) string { +func ParseMinID(value string, defaultValue string) string { if value == "" { return defaultValue } @@ -95,6 +118,19 @@ func ParseMaxID(value string, defaultValue string) string { 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 { + return 0, err + } + + return i, nil +} + +func ParseLocal(value string, defaultValue bool) (bool, gtserror.WithCode) { + return parseBool(value, defaultValue, LocalKey) +} + 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) diff --git a/internal/db/account.go b/internal/db/account.go index 45276f41f..7cdf7b57f 100644 --- a/internal/db/account.go +++ b/internal/db/account.go @@ -19,9 +19,11 @@ package db import ( "context" + "net/netip" "time" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/paging" ) // Account contains functions related to account getting/setting/creation. @@ -56,6 +58,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 netip.Addr, + page *paging.Page, + ) ( + []*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 diff --git a/internal/db/bundb/account.go b/internal/db/bundb/account.go index 1ecf28e42..45e67c10b 100644 --- a/internal/db/bundb/account.go +++ b/internal/db/bundb/account.go @@ -20,6 +20,8 @@ package bundb import ( "context" "errors" + "fmt" + "net/netip" "slices" "strings" "time" @@ -31,6 +33,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/paging" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/util" "github.com/uptrace/bun" @@ -249,6 +252,257 @@ 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 netip.Addr, + page *paging.Page, +) ( + []*gtsmodel.Account, + error, +) { + var ( + // local users lists, + // required for some + // limiting parameters. + users []*gtsmodel.User + + // lazyLoadUsers only loads the users + // slice if it's required by params. + lazyLoadUsers = func() (err error) { + if users == nil { + users, err = a.state.DB.GetAllUsers(gtscontext.SetBarebones(ctx)) + if err != nil { + return fmt.Errorf("error getting users: %w", err) + } + } + return nil + } + + // Get paging params. + // + // Note this may be min_id OR since_id + // from the API, this gets handled below + // when checking order to reverse slice. + minID = page.GetMin() + maxID = page.GetMax() + limit = page.GetLimit() + order = page.GetOrder() + + // Make educated guess for slice size + accountIDs = make([]string, 0, limit) + accountIDIn []string + + useAccountIDIn bool + ) + + 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) + } + + // 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) + } + + q = q.Where("? > ?", bun.Ident("account.created_at"), minIDAcct.CreatedAt) + } + + switch status { + + case "active": + // Get only enabled accounts. + if err := lazyLoadUsers(); err != nil { + return nil, err + } + for _, user := range users { + if !*user.Disabled { + accountIDIn = append(accountIDIn, user.AccountID) + } + } + useAccountIDIn = true + + case "pending": + // Get only unapproved accounts. + if err := lazyLoadUsers(); err != nil { + return nil, err + } + for _, user := range users { + if !*user.Approved { + accountIDIn = append(accountIDIn, user.AccountID) + } + } + useAccountIDIn = true + + case "disabled": + // Get only disabled accounts. + if err := lazyLoadUsers(); err != nil { + return nil, err + } + 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. + if err := lazyLoadUsers(); err != nil { + return nil, err + } + 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 != "" { + if err := lazyLoadUsers(); err != nil { + return nil, err + } + for _, user := range users { + if user.Email == email || user.UnconfirmedEmail == email { + accountIDIn = append(accountIDIn, user.AccountID) + } + } + useAccountIDIn = true + } + + // Use ip if not zero value. + if ip.IsValid() { + if err := lazyLoadUsers(); err != nil { + return nil, err + } + for _, user := range users { + if user.SignUpIP.String() == ip.String() { + accountIDIn = append(accountIDIn, user.AccountID) + } + } + useAccountIDIn = true + } + + if origin == "local" && !useAccountIDIn { + // In the case we're not already limiting + // by specific subset of account IDs, just + // use existing list of user.AccountIDs + // instead of adding WHERE to the query. + if err := lazyLoadUsers(); err != nil { + return nil, err + } + for _, user := range users { + accountIDIn = append(accountIDIn, user.AccountID) + } + useAccountIDIn = true + + } else if origin == "remote" { + if useAccountIDIn { + // useAccountIDIn specifically indicates + // a parameter that limits querying to + // local accounts, there will be none. + return nil, nil + } + + // Get only remote accounts. + q = q.Where("? IS NOT NULL", bun.Ident("account.domain")) + } + + if useAccountIDIn { + if len(accountIDIn) == 0 { + // There will be no + // possible answer. + return nil, nil + } + + q = q.Where("? IN (?)", bun.Ident("account.id"), bun.In(accountIDIn)) + } + + if limit > 0 { + // Limit amount of + // accounts returned. + q = q.Limit(limit) + } + + if order == paging.OrderAscending { + // Page up. + q = q.Order("account.created_at ASC") + } else { + // Page down. + q = q.Order("account.created_at DESC") + } + + 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. + if order == paging.OrderAscending { + slices.Reverse(accountIDs) + } + + // 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) { diff --git a/internal/db/bundb/account_test.go b/internal/db/bundb/account_test.go index 21e04dedc..dd96543b6 100644 --- a/internal/db/bundb/account_test.go +++ b/internal/db/bundb/account_test.go @@ -23,6 +23,7 @@ import ( "crypto/rsa" "errors" "fmt" + "net/netip" "reflect" "strings" "testing" @@ -33,6 +34,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/db/bundb" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/paging" "github.com/superseriousbusiness/gotosocial/internal/util" "github.com/uptrace/bun" ) @@ -491,6 +493,189 @@ 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 netip.Addr + page *paging.Page = nil + ) + + 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, 9) +} + +func (suite *AccountTestSuite) TestGetAccountsModsOnly() { + var ( + ctx = context.Background() + origin = "" + status = "" + mods = true + invitedBy = "" + username = "" + displayName = "" + domain = "" + email = "" + ip netip.Addr + page = &paging.Page{ + Limit: 100, + } + ) + + 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, 1) +} + +func (suite *AccountTestSuite) TestGetAccountsLocalWithEmail() { + var ( + ctx = context.Background() + origin = "local" + status = "" + mods = false + invitedBy = "" + username = "" + displayName = "" + domain = "" + email = "tortle.dude@example.org" + ip netip.Addr + page = &paging.Page{ + Limit: 100, + } + ) + + 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, 1) +} + +func (suite *AccountTestSuite) TestGetAccountsWithIP() { + var ( + ctx = context.Background() + origin = "" + status = "" + mods = false + invitedBy = "" + username = "" + displayName = "" + domain = "" + email = "" + ip = netip.MustParseAddr("199.222.111.89") + page = &paging.Page{ + Limit: 100, + } + ) + + 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, 1) +} + +func (suite *AccountTestSuite) TestGetPendingAccounts() { + var ( + ctx = context.Background() + origin = "" + status = "pending" + mods = false + invitedBy = "" + username = "" + displayName = "" + domain = "" + email = "" + ip netip.Addr + page = &paging.Page{ + Limit: 100, + } + ) + + 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, 1) +} + func TestAccountTestSuite(t *testing.T) { suite.Run(t, new(AccountTestSuite)) } diff --git a/internal/db/bundb/user.go b/internal/db/bundb/user.go index 2854c0caa..f0221eeb1 100644 --- a/internal/db/bundb/user.go +++ b/internal/db/bundb/user.go @@ -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 +} diff --git a/internal/db/user.go b/internal/db/user.go index c762ef2b3..28fa59130 100644 --- a/internal/db/user.go +++ b/internal/db/user.go @@ -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) } diff --git a/internal/email/email_test.go b/internal/email/email_test.go index 34d0d1c2f..b57562cb5 100644 --- a/internal/email/email_test.go +++ b/internal/email/email_test.go @@ -50,7 +50,7 @@ func (suite *EmailTestSuite) TestTemplateConfirm() { suite.sender.SendConfirmEmail("user@example.org", confirmData) suite.Len(suite.sentEmails, 1) - suite.Equal("To: user@example.org\r\nFrom: test@example.org\r\nSubject: GoToSocial Email Confirmation\r\nMIME-Version: 1.0\r\nContent-Transfer-Encoding: 8bit\r\nContent-Type: text/plain; charset=\"UTF-8\"\r\n\r\nHello test!\r\n\r\nYou are receiving this mail because you've requested an account on https://example.org.\r\n\r\nTo use your account, you must confirm that this is your email address.\r\n\r\nTo confirm your email, paste the following in your browser's address bar:\r\n\r\nhttps://example.org/confirm_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa\r\n\r\nIf you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of https://example.org.\r\n\r\n", suite.sentEmails["user@example.org"]) + suite.Equal("To: user@example.org\r\nFrom: test@example.org\r\nSubject: GoToSocial Email Confirmation\r\nMIME-Version: 1.0\r\nContent-Transfer-Encoding: 8bit\r\nContent-Type: text/plain; charset=\"UTF-8\"\r\n\r\nHello test!\r\n\r\nYou are receiving this mail because you've requested an account on https://example.org.\r\n\r\nTo use your account, you must confirm that this is your email address.\r\n\r\nTo confirm your email, paste the following in your browser's address bar:\r\n\r\nhttps://example.org/confirm_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa\r\n\r\n---\r\n\r\nIf you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of https://example.org.\r\n\r\n", suite.sentEmails["user@example.org"]) } func (suite *EmailTestSuite) TestTemplateReset() { @@ -63,7 +63,7 @@ func (suite *EmailTestSuite) TestTemplateReset() { suite.sender.SendResetEmail("user@example.org", resetData) suite.Len(suite.sentEmails, 1) - suite.Equal("To: user@example.org\r\nFrom: test@example.org\r\nSubject: GoToSocial Password Reset\r\nMIME-Version: 1.0\r\nContent-Transfer-Encoding: 8bit\r\nContent-Type: text/plain; charset=\"UTF-8\"\r\n\r\nHello test!\r\n\r\nYou are receiving this mail because a password reset has been requested for your account on https://example.org.\r\n\r\nTo reset your password, paste the following in your browser's address bar:\r\n\r\nhttps://example.org/reset_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa\r\n\r\nIf you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of https://example.org.\r\n\r\n", suite.sentEmails["user@example.org"]) + suite.Equal("To: user@example.org\r\nFrom: test@example.org\r\nSubject: GoToSocial Password Reset\r\nMIME-Version: 1.0\r\nContent-Transfer-Encoding: 8bit\r\nContent-Type: text/plain; charset=\"UTF-8\"\r\n\r\nHello test!\r\n\r\nYou are receiving this mail because a password reset has been requested for your account on https://example.org.\r\n\r\nTo reset your password, paste the following in your browser's address bar:\r\n\r\nhttps://example.org/reset_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa\r\n\r\n---\r\n\r\nIf you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of https://example.org.\r\n\r\n", suite.sentEmails["user@example.org"]) } func (suite *EmailTestSuite) TestTemplateReportRemoteToLocal() { @@ -166,7 +166,7 @@ func (suite *EmailTestSuite) TestTemplateReportClosedOK() { suite.FailNow(err.Error()) } suite.Len(suite.sentEmails, 1) - suite.Equal("To: user@example.org\r\nFrom: test@example.org\r\nSubject: GoToSocial Report Closed\r\nMIME-Version: 1.0\r\nContent-Transfer-Encoding: 8bit\r\nContent-Type: text/plain; charset=\"UTF-8\"\r\n\r\nHello !\r\n\r\nYou recently reported the account @foss_satan@fossbros-anonymous.io to the moderator(s) of Test Instance (https://example.org).\r\n\r\nThe report you submitted has now been closed.\r\n\r\nThe moderator who closed the report left the following comment: User was yeeted. Thank you for reporting!\r\n\r\n", suite.sentEmails["user@example.org"]) + suite.Equal("To: user@example.org\r\nFrom: test@example.org\r\nSubject: GoToSocial Report Closed\r\nMIME-Version: 1.0\r\nContent-Transfer-Encoding: 8bit\r\nContent-Type: text/plain; charset=\"UTF-8\"\r\n\r\nHello !\r\n\r\nYou recently reported the account @foss_satan@fossbros-anonymous.io to the moderator(s) of Test Instance (https://example.org).\r\n\r\nThe report you submitted has now been closed.\r\n\r\nThe moderator who closed the report left the following comment: User was yeeted. Thank you for reporting!\r\n\r\n---\r\n\r\nIf you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of https://example.org.\r\n\r\n", suite.sentEmails["user@example.org"]) } func (suite *EmailTestSuite) TestTemplateReportClosedLocalAccountNoComment() { @@ -182,7 +182,7 @@ func (suite *EmailTestSuite) TestTemplateReportClosedLocalAccountNoComment() { suite.FailNow(err.Error()) } suite.Len(suite.sentEmails, 1) - suite.Equal("To: user@example.org\r\nFrom: test@example.org\r\nSubject: GoToSocial Report Closed\r\nMIME-Version: 1.0\r\nContent-Transfer-Encoding: 8bit\r\nContent-Type: text/plain; charset=\"UTF-8\"\r\n\r\nHello !\r\n\r\nYou recently reported the account @1happyturtle to the moderator(s) of Test Instance (https://example.org).\r\n\r\nThe report you submitted has now been closed.\r\n\r\nThe moderator who closed the report did not leave a comment.\r\n\r\n", suite.sentEmails["user@example.org"]) + suite.Equal("To: user@example.org\r\nFrom: test@example.org\r\nSubject: GoToSocial Report Closed\r\nMIME-Version: 1.0\r\nContent-Transfer-Encoding: 8bit\r\nContent-Type: text/plain; charset=\"UTF-8\"\r\n\r\nHello !\r\n\r\nYou recently reported the account @1happyturtle to the moderator(s) of Test Instance (https://example.org).\r\n\r\nThe report you submitted has now been closed.\r\n\r\nThe moderator who closed the report did not leave a comment.\r\n\r\n---\r\n\r\nIf you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of https://example.org.\r\n\r\n", suite.sentEmails["user@example.org"]) } func TestEmailTestSuite(t *testing.T) { diff --git a/internal/email/noopsender.go b/internal/email/noopsender.go index 44aa86dba..20d7df2eb 100644 --- a/internal/email/noopsender.go +++ b/internal/email/noopsender.go @@ -72,6 +72,14 @@ func (s *noopSender) SendNewSignupEmail(toAddresses []string, data NewSignupData return s.sendTemplate(newSignupTemplate, newSignupSubject, data, toAddresses...) } +func (s *noopSender) SendSignupApprovedEmail(toAddress string, data SignupApprovedData) error { + return s.sendTemplate(signupApprovedTemplate, signupApprovedSubject, data, toAddress) +} + +func (s *noopSender) SendSignupRejectedEmail(toAddress string, data SignupRejectedData) error { + return s.sendTemplate(signupRejectedTemplate, signupRejectedSubject, data, toAddress) +} + func (s *noopSender) sendTemplate(template string, subject string, data any, toAddresses ...string) error { buf := &bytes.Buffer{} if err := s.template.ExecuteTemplate(buf, template, data); err != nil { diff --git a/internal/email/sender.go b/internal/email/sender.go index 78338a0dd..a3efa6124 100644 --- a/internal/email/sender.go +++ b/internal/email/sender.go @@ -53,6 +53,14 @@ type Sender interface { // It is expected that the toAddresses have already been filtered to ensure // that they all belong to active admins + moderators. SendNewSignupEmail(toAddress []string, data NewSignupData) error + + // SendSignupApprovedEmail sends an email to the given address + // that their sign-up request has been approved by a moderator. + SendSignupApprovedEmail(toAddress string, data SignupApprovedData) error + + // SendSignupRejectedEmail sends an email to the given address + // that their sign-up request has been rejected by a moderator. + SendSignupRejectedEmail(toAddress string, data SignupRejectedData) error } // NewSender returns a new email Sender interface with the given configuration, or an error if something goes wrong. diff --git a/internal/email/signup.go b/internal/email/signup.go index 84162c21e..2eaffc8a9 100644 --- a/internal/email/signup.go +++ b/internal/email/signup.go @@ -40,3 +40,39 @@ type NewSignupData struct { func (s *sender) SendNewSignupEmail(toAddresses []string, data NewSignupData) error { return s.sendTemplate(newSignupTemplate, newSignupSubject, data, toAddresses...) } + +var ( + signupApprovedTemplate = "email_signup_approved.tmpl" + signupApprovedSubject = "GoToSocial Sign-Up Approved" +) + +type SignupApprovedData struct { + // Username to be addressed. + Username string + // URL of the instance to present to the receiver. + InstanceURL string + // Name of the instance to present to the receiver. + InstanceName string +} + +func (s *sender) SendSignupApprovedEmail(toAddress string, data SignupApprovedData) error { + return s.sendTemplate(signupApprovedTemplate, signupApprovedSubject, data, toAddress) +} + +var ( + signupRejectedTemplate = "email_signup_rejected.tmpl" + signupRejectedSubject = "GoToSocial Sign-Up Rejected" +) + +type SignupRejectedData struct { + // Message to the rejected applicant. + Message string + // URL of the instance to present to the receiver. + InstanceURL string + // Name of the instance to present to the receiver. + InstanceName string +} + +func (s *sender) SendSignupRejectedEmail(toAddress string, data SignupRejectedData) error { + return s.sendTemplate(signupRejectedTemplate, signupRejectedSubject, data, toAddress) +} diff --git a/internal/processing/admin/account.go b/internal/processing/admin/accountaction.go index 155d8c0b4..155d8c0b4 100644 --- a/internal/processing/admin/account.go +++ b/internal/processing/admin/accountaction.go diff --git a/internal/processing/admin/accountapprove.go b/internal/processing/admin/accountapprove.go new file mode 100644 index 000000000..e34cb18e3 --- /dev/null +++ b/internal/processing/admin/accountapprove.go @@ -0,0 +1,79 @@ +// 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) 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()) + } + + // Get a lock on the account URI, + // to ensure it's not also being + // rejected at the same time! + unlock := p.state.ClientLocks.Lock(user.Account.URI) + defer unlock() + + if !*user.Approved { + // 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) + } + + // Optimistically set approved to true and + // clear sign-up IP to reflect state that + // will be produced by side effects. + apiAccount.Approved = true + apiAccount.IP = nil + + return apiAccount, nil +} diff --git a/internal/processing/admin/accountapprove_test.go b/internal/processing/admin/accountapprove_test.go new file mode 100644 index 000000000..b6ca1ed32 --- /dev/null +++ b/internal/processing/admin/accountapprove_test.go @@ -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_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +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) + suite.Nil(acct.IP) + + // Wait for processor to + // handle side effects. + var ( + dbUser *gtsmodel.User + err error + ) + if !testrig.WaitFor(func() bool { + dbUser, err = suite.state.DB.GetUserByID(ctx, targetUser.ID) + return err == nil && dbUser != nil && *dbUser.Approved + }) { + suite.FailNow("waiting for approved user") + } +} + +func TestAdminApproveTestSuite(t *testing.T) { + suite.Run(t, new(AdminApproveTestSuite)) +} diff --git a/internal/processing/admin/accountget.go b/internal/processing/admin/accountget.go new file mode 100644 index 000000000..5a3c34c62 --- /dev/null +++ b/internal/processing/admin/accountget.go @@ -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 +} diff --git a/internal/processing/admin/accountreject.go b/internal/processing/admin/accountreject.go new file mode 100644 index 000000000..bc7a1c20a --- /dev/null +++ b/internal/processing/admin/accountreject.go @@ -0,0 +1,113 @@ +// 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) + } + + // Set approved to false on the API model, to + // reflect the changes that will occur + // asynchronously in the processor. + apiAccount.Approved = false + + // Ensure we an email address. + var email string + if user.Email != "" { + email = user.Email + } else { + email = user.UnconfirmedEmail + } + + // Create a denied user entry for + // the worker to process + store. + deniedUser := >smodel.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, + } + + // 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 +} diff --git a/internal/processing/admin/accountreject_test.go b/internal/processing/admin/accountreject_test.go new file mode 100644 index 000000000..071401afc --- /dev/null +++ b/internal/processing/admin/accountreject_test.go @@ -0,0 +1,142 @@ +// 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" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +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) + suite.False(acct.Approved) + + // Wait for processor to + // handle side effects. + var ( + deniedUser *gtsmodel.DeniedUser + err error + ) + if !testrig.WaitFor(func() bool { + deniedUser, err = suite.state.DB.GetDeniedUserByID(ctx, targetUser.ID) + return deniedUser != nil && err == nil + }) { + suite.FailNow("waiting for denied user") + } + + // 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)) +} diff --git a/internal/processing/admin/accounts.go b/internal/processing/admin/accounts.go new file mode 100644 index 000000000..ca35b0a30 --- /dev/null +++ b/internal/processing/admin/accounts.go @@ -0,0 +1,272 @@ +// 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/netip" + "net/url" + "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/paging" +) + +var ( + accountsValidOrigins = []string{"local", "remote"} + accountsValidStatuses = []string{"active", "pending", "disabled", "silenced", "suspended"} + accountsValidPermissions = []string{"staff"} +) + +func (p *Processor) AccountsGet( + ctx context.Context, + request *apimodel.AdminGetAccountsRequest, + page *paging.Page, +) ( + *apimodel.PageableResponse, + gtserror.WithCode, +) { + // Validate "origin". + if v := request.Origin; v != "" { + if !slices.Contains(accountsValidOrigins, v) { + err := fmt.Errorf( + "origin %s not recognized; valid choices are %+v", + v, accountsValidOrigins, + ) + return nil, gtserror.NewErrorBadRequest(err, err.Error()) + } + } + + // Validate "status". + if v := request.Status; v != "" { + if !slices.Contains(accountsValidStatuses, v) { + err := fmt.Errorf( + "status %s not recognized; valid choices are %+v", + v, accountsValidStatuses, + ) + return nil, gtserror.NewErrorBadRequest(err, err.Error()) + } + } + + // Validate "permissions". + if v := request.Permissions; v != "" { + if !slices.Contains(accountsValidPermissions, v) { + err := fmt.Errorf( + "permissions %s not recognized; valid choices are %+v", + v, accountsValidPermissions, + ) + return nil, gtserror.NewErrorBadRequest(err, err.Error()) + } + } + + // Validate/parse IP. + var ip netip.Addr + if v := request.IP; v != "" { + var err error + ip, err = netip.ParseAddr(request.IP) + if err != nil { + err := fmt.Errorf("invalid ip provided: %w", err) + 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, + page, + ) + 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 paging.EmptyResponse(), nil + } + + hi := accounts[count-1].ID + lo := 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, lo, hi, request, page) + + case 2: + return packageAccountsV2(items, lo, hi, request, page) + + default: + log.Panic(ctx, "api version was neither 1 nor 2") + return nil, nil + } +} + +func packageAccountsV1( + items []interface{}, + loID, hiID string, + request *apimodel.AdminGetAccountsRequest, + page *paging.Page, +) (*apimodel.PageableResponse, gtserror.WithCode) { + queryParams := make(url.Values, 8) + + // Translate origin to v1. + if v := request.Origin; v != "" { + var k string + + if v == "local" { + k = apiutil.LocalKey + } else { + k = apiutil.AdminRemoteKey + } + + queryParams.Add(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 + } + + queryParams.Add(k, "true") + } + + if v := request.Username; v != "" { + queryParams.Add(apiutil.UsernameKey, v) + } + + if v := request.DisplayName; v != "" { + queryParams.Add(apiutil.AdminDisplayNameKey, v) + } + + if v := request.ByDomain; v != "" { + queryParams.Add(apiutil.AdminByDomainKey, v) + } + + if v := request.Email; v != "" { + queryParams.Add(apiutil.AdminEmailKey, v) + } + + if v := request.IP; v != "" { + queryParams.Add(apiutil.AdminIPKey, v) + } + + // Translate permissions to v1. + if v := request.Permissions; v != "" { + queryParams.Add(apiutil.AdminStaffKey, v) + } + + return paging.PackageResponse(paging.ResponseParams{ + Items: items, + Path: "/api/v1/admin/accounts", + Next: page.Next(loID, hiID), + Prev: page.Prev(loID, hiID), + Query: queryParams, + }), nil +} + +func packageAccountsV2( + items []interface{}, + loID, hiID string, + request *apimodel.AdminGetAccountsRequest, + page *paging.Page, +) (*apimodel.PageableResponse, gtserror.WithCode) { + queryParams := make(url.Values, 9) + + if v := request.Origin; v != "" { + queryParams.Add(apiutil.AdminOriginKey, v) + } + + if v := request.Status; v != "" { + queryParams.Add(apiutil.AdminStatusKey, v) + } + + if v := request.Permissions; v != "" { + queryParams.Add(apiutil.AdminPermissionsKey, v) + } + + if v := request.InvitedBy; v != "" { + queryParams.Add(apiutil.AdminInvitedByKey, v) + } + + if v := request.Username; v != "" { + queryParams.Add(apiutil.UsernameKey, v) + } + + if v := request.DisplayName; v != "" { + queryParams.Add(apiutil.AdminDisplayNameKey, v) + } + + if v := request.ByDomain; v != "" { + queryParams.Add(apiutil.AdminByDomainKey, v) + } + + if v := request.Email; v != "" { + queryParams.Add(apiutil.AdminEmailKey, v) + } + + if v := request.IP; v != "" { + queryParams.Add(apiutil.AdminIPKey, v) + } + + return paging.PackageResponse(paging.ResponseParams{ + Items: items, + Path: "/api/v2/admin/accounts", + Next: page.Next(loID, hiID), + Prev: page.Prev(loID, hiID), + Query: queryParams, + }), nil +} diff --git a/internal/processing/workers/fromclientapi.go b/internal/processing/workers/fromclientapi.go index ed513c331..37c330cf0 100644 --- a/internal/processing/workers/fromclientapi.go +++ b/internal/processing/workers/fromclientapi.go @@ -33,6 +33,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/processing/account" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/internal/util" ) // clientAPI wraps processing functions @@ -141,6 +142,10 @@ func (p *Processor) ProcessFromClientAPI(ctx context.Context, cMsg messages.From // ACCEPT FOLLOW (request) case ap.ActivityFollow: return p.clientAPI.AcceptFollow(ctx, cMsg) + + // ACCEPT PROFILE/ACCOUNT (sign-up) + case ap.ObjectProfile, ap.ActorPerson: + return p.clientAPI.AcceptAccount(ctx, cMsg) } // REJECT SOMETHING @@ -150,6 +155,10 @@ func (p *Processor) ProcessFromClientAPI(ctx context.Context, cMsg messages.From // REJECT FOLLOW (request) case ap.ActivityFollow: return p.clientAPI.RejectFollowRequest(ctx, cMsg) + + // REJECT PROFILE/ACCOUNT (sign-up) + case ap.ObjectProfile, ap.ActorPerson: + return p.clientAPI.RejectAccount(ctx, cMsg) } // UNDO SOMETHING @@ -685,3 +694,66 @@ func (p *clientAPI) MoveAccount(ctx context.Context, cMsg messages.FromClientAPI return nil } + +func (p *clientAPI) AcceptAccount(ctx context.Context, cMsg messages.FromClientAPI) error { + newUser, ok := cMsg.GTSModel.(*gtsmodel.User) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.User", cMsg.GTSModel) + } + + // Mark user as approved + clear sign-up IP. + newUser.Approved = util.Ptr(true) + newUser.SignUpIP = nil + if err := p.state.DB.UpdateUser(ctx, newUser, "approved", "sign_up_ip"); err != nil { + // Error now means we should return without + // sending email + let admin try to approve again. + return gtserror.Newf("db error updating user %s: %w", newUser.ID, err) + } + + // Send "your sign-up has been approved" email to the new user. + if err := p.surface.emailUserSignupApproved(ctx, newUser); err != nil { + log.Errorf(ctx, "error emailing: %v", err) + } + + return nil +} + +func (p *clientAPI) RejectAccount(ctx context.Context, cMsg messages.FromClientAPI) error { + deniedUser, ok := cMsg.GTSModel.(*gtsmodel.DeniedUser) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.DeniedUser", cMsg.GTSModel) + } + + // Remove the account. + if err := p.state.DB.DeleteAccount(ctx, cMsg.TargetAccount.ID); err != nil { + log.Errorf(ctx, + "db error deleting account %s: %v", + cMsg.TargetAccount.ID, err, + ) + } + + // Remove the user. + if err := p.state.DB.DeleteUserByID(ctx, deniedUser.ID); err != nil { + log.Errorf(ctx, + "db error deleting user %s: %v", + deniedUser.ID, err, + ) + } + + // Store the deniedUser entry. + if err := p.state.DB.PutDeniedUser(ctx, deniedUser); err != nil { + log.Errorf(ctx, + "db error putting denied user %s: %v", + deniedUser.ID, err, + ) + } + + if *deniedUser.SendEmail { + // Send "your sign-up has been rejected" email to the denied user. + if err := p.surface.emailUserSignupRejected(ctx, deniedUser); err != nil { + log.Errorf(ctx, "error emailing: %v", err) + } + } + + return nil +} diff --git a/internal/processing/workers/surfaceemail.go b/internal/processing/workers/surfaceemail.go index c00b22c86..3a5b5e7f4 100644 --- a/internal/processing/workers/surfaceemail.go +++ b/internal/processing/workers/surfaceemail.go @@ -129,6 +129,69 @@ func (s *surface) emailUserPleaseConfirm(ctx context.Context, user *gtsmodel.Use return nil } +// emailUserSignupApproved emails the given user +// to inform them their sign-up has been approved. +func (s *surface) emailUserSignupApproved(ctx context.Context, user *gtsmodel.User) error { + // User may have been approved without + // their email address being confirmed + // yet. Just send to whatever we have. + emailAddr := user.Email + if emailAddr == "" { + emailAddr = user.UnconfirmedEmail + } + + instance, err := s.state.DB.GetInstance(ctx, config.GetHost()) + if err != nil { + return gtserror.Newf("db error getting instance: %w", err) + } + + // Assemble email contents and send the email. + if err := s.emailSender.SendSignupApprovedEmail( + emailAddr, + email.SignupApprovedData{ + Username: user.Account.Username, + InstanceURL: instance.URI, + InstanceName: instance.Title, + }, + ); err != nil { + return err + } + + // Email sent, update the user + // entry with the emailed time. + now := time.Now() + user.LastEmailedAt = now + + if err := s.state.DB.UpdateUser( + ctx, + user, + "last_emailed_at", + ); err != nil { + return gtserror.Newf("error updating user entry after email sent: %w", err) + } + + return nil +} + +// emailUserSignupApproved emails the given user +// to inform them their sign-up has been approved. +func (s *surface) emailUserSignupRejected(ctx context.Context, deniedUser *gtsmodel.DeniedUser) error { + instance, err := s.state.DB.GetInstance(ctx, config.GetHost()) + if err != nil { + return gtserror.Newf("db error getting instance: %w", err) + } + + // Assemble email contents and send the email. + return s.emailSender.SendSignupRejectedEmail( + deniedUser.Email, + email.SignupRejectedData{ + Message: deniedUser.Message, + InstanceURL: instance.URI, + InstanceName: instance.Title, + }, + ) +} + // emailAdminReportOpened emails all active moderators/admins // of this instance that a new report has been created. func (s *surface) emailAdminReportOpened(ctx context.Context, report *gtsmodel.Report) error { @@ -193,7 +256,7 @@ func (s *surface) emailAdminNewSignup(ctx context.Context, newUser *gtsmodel.Use SignupEmail: newUser.UnconfirmedEmail, SignupUsername: newUser.Account.Username, SignupReason: newUser.Reason, - SignupURL: "TODO", + SignupURL: instance.URI + "/settings/admin/accounts/" + newUser.AccountID, } if err := s.emailSender.SendNewSignupEmail(toAddresses, newSignupData); err != nil { diff --git a/internal/web/customcss.go b/internal/web/customcss.go index b23ebce8e..b4072f2a7 100644 --- a/internal/web/customcss.go +++ b/internal/web/customcss.go @@ -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 diff --git a/internal/web/profile.go b/internal/web/profile.go index a4809a72d..1dbf5c73d 100644 --- a/internal/web/profile.go +++ b/internal/web/profile.go @@ -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 diff --git a/internal/web/rss.go b/internal/web/rss.go index 2d98efcb3..ced74ed6b 100644 --- a/internal/web/rss.go +++ b/internal/web/rss.go @@ -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 diff --git a/internal/web/thread.go b/internal/web/thread.go index ffec565e6..05bd63ebe 100644 --- a/internal/web/thread.go +++ b/internal/web/thread.go @@ -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 |