diff options
Diffstat (limited to 'internal')
37 files changed, 2266 insertions, 652 deletions
diff --git a/internal/api/client/admin/admin.go b/internal/api/client/admin/admin.go index 605c53731..3d8e88c42 100644 --- a/internal/api/client/admin/admin.go +++ b/internal/api/client/admin/admin.go @@ -31,6 +31,8 @@ const ( EmojiCategoriesPath = EmojiPath + "/categories" DomainBlocksPath = BasePath + "/domain_blocks" DomainBlocksPathWithID = DomainBlocksPath + "/:" + IDKey + DomainAllowsPath = BasePath + "/domain_allows" + DomainAllowsPathWithID = DomainAllowsPath + "/:" + IDKey DomainKeysExpirePath = BasePath + "/domain_keys_expire" AccountsPath = BasePath + "/accounts" AccountsPathWithID = AccountsPath + "/:" + IDKey @@ -84,6 +86,12 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H attachHandler(http.MethodGet, DomainBlocksPathWithID, m.DomainBlockGETHandler) attachHandler(http.MethodDelete, DomainBlocksPathWithID, m.DomainBlockDELETEHandler) + // domain allow stuff + attachHandler(http.MethodPost, DomainAllowsPath, m.DomainAllowsPOSTHandler) + attachHandler(http.MethodGet, DomainAllowsPath, m.DomainAllowsGETHandler) + attachHandler(http.MethodGet, DomainAllowsPathWithID, m.DomainAllowGETHandler) + attachHandler(http.MethodDelete, DomainAllowsPathWithID, m.DomainAllowDELETEHandler) + // domain maintenance stuff attachHandler(http.MethodPost, DomainKeysExpirePath, m.DomainKeysExpirePOSTHandler) diff --git a/internal/api/client/admin/domainallowcreate.go b/internal/api/client/admin/domainallowcreate.go new file mode 100644 index 000000000..e8700f673 --- /dev/null +++ b/internal/api/client/admin/domainallowcreate.go @@ -0,0 +1,128 @@ +// 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 ( + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +// DomainAllowsPOSTHandler swagger:operation POST /api/v1/admin/domain_allows domainAllowCreate +// +// Create one or more domain allows, from a string or a file. +// +// You have two options when using this endpoint: either you can set `import` to `true` and +// upload a file containing multiple domain allows, JSON-formatted, or you can leave import as +// `false`, and just add one domain allow. +// +// The format of the json file should be something like: `[{"domain":"example.org"},{"domain":"whatever.com","public_comment":"they smell"}]` +// +// --- +// tags: +// - admin +// +// consumes: +// - multipart/form-data +// +// produces: +// - application/json +// +// parameters: +// - +// name: import +// in: query +// description: >- +// Signal that a list of domain allows is being imported as a file. +// If set to `true`, then 'domains' must be present as a JSON-formatted file. +// If set to `false`, then `domains` will be ignored, and `domain` must be present. +// type: boolean +// default: false +// - +// name: domains +// in: formData +// description: >- +// JSON-formatted list of domain allows to import. +// This is only used if `import` is set to `true`. +// type: file +// - +// name: domain +// in: formData +// description: >- +// Single domain to allow. +// Used only if `import` is not `true`. +// type: string +// - +// name: obfuscate +// in: formData +// description: >- +// Obfuscate the name of the domain when serving it publicly. +// Eg., `example.org` becomes something like `ex***e.org`. +// Used only if `import` is not `true`. +// type: boolean +// - +// name: public_comment +// in: formData +// description: >- +// Public comment about this domain allow. +// This will be displayed alongside the domain allow if you choose to share allows. +// Used only if `import` is not `true`. +// type: string +// - +// name: private_comment +// in: formData +// description: >- +// Private comment about this domain allow. Will only be shown to other admins, so this +// is a useful way of internally keeping track of why a certain domain ended up allowed. +// Used only if `import` is not `true`. +// type: string +// +// security: +// - OAuth2 Bearer: +// - admin +// +// responses: +// '200': +// description: >- +// The newly created domain allow, if `import` != `true`. +// If a list has been imported, then an `array` of newly created domain allows will be returned instead. +// schema: +// "$ref": "#/definitions/domainPermission" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +// '406': +// description: not acceptable +// '409': +// description: >- +// Conflict: There is already an admin action running that conflicts with this action. +// Check the error message in the response body for more information. This is a temporary +// error; it should be possible to process this action if you try again in a bit. +// '500': +// description: internal server error +func (m *Module) DomainAllowsPOSTHandler(c *gin.Context) { + m.createDomainPermissions(c, + gtsmodel.DomainPermissionAllow, + m.processor.Admin().DomainPermissionCreate, + m.processor.Admin().DomainPermissionsImport, + ) +} diff --git a/internal/api/client/admin/domainallowdelete.go b/internal/api/client/admin/domainallowdelete.go new file mode 100644 index 000000000..6237e403f --- /dev/null +++ b/internal/api/client/admin/domainallowdelete.go @@ -0,0 +1,72 @@ +// 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 ( + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +// DomainAllowDELETEHandler swagger:operation DELETE /api/v1/admin/domain_allows/{id} domainAllowDelete +// +// Delete domain allow with the given ID. +// +// --- +// tags: +// - admin +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: The id of the domain allow. +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - admin +// +// responses: +// '200': +// description: The domain allow that was just deleted. +// schema: +// "$ref": "#/definitions/domainPermission" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +// '406': +// description: not acceptable +// '409': +// description: >- +// Conflict: There is already an admin action running that conflicts with this action. +// Check the error message in the response body for more information. This is a temporary +// error; it should be possible to process this action if you try again in a bit. +// '500': +// description: internal server error +func (m *Module) DomainAllowDELETEHandler(c *gin.Context) { + m.deleteDomainPermission(c, gtsmodel.DomainPermissionAllow) +} diff --git a/internal/api/client/admin/domainallowget.go b/internal/api/client/admin/domainallowget.go new file mode 100644 index 000000000..aa21743fa --- /dev/null +++ b/internal/api/client/admin/domainallowget.go @@ -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 + +import ( + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +// DomainAllowGETHandler swagger:operation GET /api/v1/admin/domain_allows/{id} domainAllowGet +// +// View domain allow with the given ID. +// +// --- +// tags: +// - admin +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: The id of the domain allow. +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - admin +// +// responses: +// '200': +// description: The requested domain allow. +// schema: +// "$ref": "#/definitions/domainPermission" +// '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) DomainAllowGETHandler(c *gin.Context) { + m.getDomainPermission(c, gtsmodel.DomainPermissionAllow) +} diff --git a/internal/api/client/admin/domainallowsget.go b/internal/api/client/admin/domainallowsget.go new file mode 100644 index 000000000..6391c7138 --- /dev/null +++ b/internal/api/client/admin/domainallowsget.go @@ -0,0 +1,73 @@ +// 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 ( + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +// DomainAllowsGETHandler swagger:operation GET /api/v1/admin/domain_allows domainAllowsGet +// +// View all domain allows currently in place. +// +// --- +// tags: +// - admin +// +// produces: +// - application/json +// +// parameters: +// - +// name: export +// type: boolean +// description: >- +// If set to `true`, then each entry in the returned list of domain allows will only consist of +// the fields `domain` and `public_comment`. This is perfect for when you want to save and share +// a list of all the domains you have allowed on your instance, so that someone else can easily import them, +// but you don't want them to see the database IDs of your allows, or private comments etc. +// in: query +// required: false +// +// security: +// - OAuth2 Bearer: +// - admin +// +// responses: +// '200': +// description: All domain allows currently in place. +// schema: +// type: array +// items: +// "$ref": "#/definitions/domainPermission" +// '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) DomainAllowsGETHandler(c *gin.Context) { + m.getDomainPermissions(c, gtsmodel.DomainPermissionAllow) +} diff --git a/internal/api/client/admin/domainblockcreate.go b/internal/api/client/admin/domainblockcreate.go index 5cf9ea279..5234561cf 100644 --- a/internal/api/client/admin/domainblockcreate.go +++ b/internal/api/client/admin/domainblockcreate.go @@ -18,15 +18,8 @@ package admin import ( - "errors" - "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/gtsmodel" ) // DomainBlocksPOSTHandler swagger:operation POST /api/v1/admin/domain_blocks domainBlockCreate @@ -108,7 +101,7 @@ import ( // The newly created domain block, if `import` != `true`. // If a list has been imported, then an `array` of newly created domain blocks will be returned instead. // schema: -// "$ref": "#/definitions/domainBlock" +// "$ref": "#/definitions/domainPermission" // '400': // description: bad request // '401': @@ -127,108 +120,9 @@ import ( // '500': // description: internal server error func (m *Module) DomainBlocksPOSTHandler(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 _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { - apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) - return - } - - importing, errWithCode := apiutil.ParseDomainBlockImport(c.Query(apiutil.DomainBlockImportKey), false) - if errWithCode != nil { - apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) - return - } - - form := new(apimodel.DomainBlockCreateRequest) - if err := c.ShouldBind(form); err != nil { - apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) - return - } - - if err := validateCreateDomainBlock(form, importing); err != nil { - err := fmt.Errorf("error validating form: %w", err) - apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) - return - } - - if !importing { - // Single domain block creation. - domainBlock, _, errWithCode := m.processor.Admin().DomainBlockCreate( - c.Request.Context(), - authed.Account, - form.Domain, - form.Obfuscate, - form.PublicComment, - form.PrivateComment, - "", // No sub ID for single block creation. - ) - if errWithCode != nil { - apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) - return - } - - c.JSON(http.StatusOK, domainBlock) - return - } - - // We're importing multiple domain blocks, - // so we're looking at a multi-status response. - multiStatus, errWithCode := m.processor.Admin().DomainBlocksImport( - c.Request.Context(), - authed.Account, - form.Domains, // Pass the file through. + m.createDomainPermissions(c, + gtsmodel.DomainPermissionBlock, + m.processor.Admin().DomainPermissionCreate, + m.processor.Admin().DomainPermissionsImport, ) - if errWithCode != nil { - apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) - return - } - - // TODO: Return 207 and multiStatus data nicely - // when supported by the admin panel. - - if multiStatus.Metadata.Failure != 0 { - failures := make(map[string]any, multiStatus.Metadata.Failure) - for _, entry := range multiStatus.Data { - // nolint:forcetypeassert - failures[entry.Resource.(string)] = entry.Message - } - - err := fmt.Errorf("one or more errors importing domain blocks: %+v", failures) - apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, err.Error()), m.processor.InstanceGetV1) - return - } - - // Success, return slice of domain blocks. - domainBlocks := make([]any, 0, multiStatus.Metadata.Success) - for _, entry := range multiStatus.Data { - domainBlocks = append(domainBlocks, entry.Resource) - } - - c.JSON(http.StatusOK, domainBlocks) -} - -func validateCreateDomainBlock(form *apimodel.DomainBlockCreateRequest, imp bool) error { - if imp { - if form.Domains.Size == 0 { - return errors.New("import was specified but list of domains is empty") - } - } else { - // add some more validation here later if necessary - if form.Domain == "" { - return errors.New("empty domain provided") - } - } - - return nil } diff --git a/internal/api/client/admin/domainblockdelete.go b/internal/api/client/admin/domainblockdelete.go index 9318bad87..a6f6619cd 100644 --- a/internal/api/client/admin/domainblockdelete.go +++ b/internal/api/client/admin/domainblockdelete.go @@ -18,14 +18,8 @@ package admin import ( - "errors" - "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" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) // DomainBlockDELETEHandler swagger:operation DELETE /api/v1/admin/domain_blocks/{id} domainBlockDelete @@ -55,7 +49,7 @@ import ( // '200': // description: The domain block that was just deleted. // schema: -// "$ref": "#/definitions/domainBlock" +// "$ref": "#/definitions/domainPermission" // '400': // description: bad request // '401': @@ -74,35 +68,5 @@ import ( // '500': // description: internal server error func (m *Module) DomainBlockDELETEHandler(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 _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { - apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) - return - } - - domainBlockID := c.Param(IDKey) - if domainBlockID == "" { - err := errors.New("no domain block id specified") - apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) - return - } - - domainBlock, _, errWithCode := m.processor.Admin().DomainBlockDelete(c.Request.Context(), authed.Account, domainBlockID) - if errWithCode != nil { - apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) - return - } - - c.JSON(http.StatusOK, domainBlock) + m.deleteDomainPermission(c, gtsmodel.DomainPermissionBlock) } diff --git a/internal/api/client/admin/domainblockget.go b/internal/api/client/admin/domainblockget.go index 87bb75a27..9e8d29905 100644 --- a/internal/api/client/admin/domainblockget.go +++ b/internal/api/client/admin/domainblockget.go @@ -18,13 +18,8 @@ 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" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) // DomainBlockGETHandler swagger:operation GET /api/v1/admin/domain_blocks/{id} domainBlockGet @@ -54,7 +49,7 @@ import ( // '200': // description: The requested domain block. // schema: -// "$ref": "#/definitions/domainBlock" +// "$ref": "#/definitions/domainPermission" // '400': // description: bad request // '401': @@ -68,40 +63,5 @@ import ( // '500': // description: internal server error func (m *Module) DomainBlockGETHandler(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 _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { - apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) - return - } - - domainBlockID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) - if errWithCode != nil { - apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) - return - } - - export, errWithCode := apiutil.ParseDomainBlockExport(c.Query(apiutil.DomainBlockExportKey), false) - if errWithCode != nil { - apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) - return - } - - domainBlock, errWithCode := m.processor.Admin().DomainBlockGet(c.Request.Context(), domainBlockID, export) - if errWithCode != nil { - apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) - return - } - - c.JSON(http.StatusOK, domainBlock) + m.getDomainPermission(c, gtsmodel.DomainPermissionBlock) } diff --git a/internal/api/client/admin/domainblocksget.go b/internal/api/client/admin/domainblocksget.go index 68947f471..bdcc03469 100644 --- a/internal/api/client/admin/domainblocksget.go +++ b/internal/api/client/admin/domainblocksget.go @@ -18,13 +18,8 @@ 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" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) // DomainBlocksGETHandler swagger:operation GET /api/v1/admin/domain_blocks domainBlocksGet @@ -60,7 +55,7 @@ import ( // schema: // type: array // items: -// "$ref": "#/definitions/domainBlock" +// "$ref": "#/definitions/domainPermission" // '400': // description: bad request // '401': @@ -74,34 +69,5 @@ import ( // '500': // description: internal server error func (m *Module) DomainBlocksGETHandler(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 _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { - apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) - return - } - - export, errWithCode := apiutil.ParseDomainBlockExport(c.Query(apiutil.DomainBlockExportKey), false) - if errWithCode != nil { - apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) - return - } - - domainBlocks, errWithCode := m.processor.Admin().DomainBlocksGet(c.Request.Context(), authed.Account, export) - if errWithCode != nil { - apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) - return - } - - c.JSON(http.StatusOK, domainBlocks) + m.getDomainPermissions(c, gtsmodel.DomainPermissionBlock) } diff --git a/internal/api/client/admin/domainpermission.go b/internal/api/client/admin/domainpermission.go new file mode 100644 index 000000000..80aa05041 --- /dev/null +++ b/internal/api/client/admin/domainpermission.go @@ -0,0 +1,295 @@ +// 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" + "mime/multipart" + "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/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +type singleDomainPermCreate func( + context.Context, + gtsmodel.DomainPermissionType, // block/allow + *gtsmodel.Account, // admin account + string, // domain + bool, // obfuscate + string, // publicComment + string, // privateComment + string, // subscriptionID +) (*apimodel.DomainPermission, string, gtserror.WithCode) + +type multiDomainPermCreate func( + context.Context, + gtsmodel.DomainPermissionType, // block/allow + *gtsmodel.Account, // admin account + *multipart.FileHeader, // domains +) (*apimodel.MultiStatus, gtserror.WithCode) + +// createDomainPemissions either creates a single domain +// permission entry (block/allow) or imports multiple domain +// permission entries (multiple blocks, multiple allows) +// using the given functions. +// +// Handling the creation of both types of permissions in +// one function in this way reduces code duplication. +func (m *Module) createDomainPermissions( + c *gin.Context, + permType gtsmodel.DomainPermissionType, + single singleDomainPermCreate, + multi multiDomainPermCreate, +) { + 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 _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + importing, errWithCode := apiutil.ParseDomainPermissionImport(c.Query(apiutil.DomainPermissionImportKey), false) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + // Parse + validate form. + form := new(apimodel.DomainPermissionRequest) + if err := c.ShouldBind(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if importing && form.Domains.Size == 0 { + err = errors.New("import was specified but list of domains is empty") + } else if form.Domain == "" { + err = errors.New("empty domain provided") + } + + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if !importing { + // Single domain permission creation. + domainBlock, _, errWithCode := single( + c.Request.Context(), + permType, + authed.Account, + form.Domain, + form.Obfuscate, + form.PublicComment, + form.PrivateComment, + "", // No sub ID for single perm creation. + ) + + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + c.JSON(http.StatusOK, domainBlock) + return + } + + // We're importing multiple domain permissions, + // so we're looking at a multi-status response. + multiStatus, errWithCode := multi( + c.Request.Context(), + permType, + authed.Account, + form.Domains, // Pass the file through. + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + // TODO: Return 207 and multiStatus data nicely + // when supported by the admin panel. + if multiStatus.Metadata.Failure != 0 { + failures := make(map[string]any, multiStatus.Metadata.Failure) + for _, entry := range multiStatus.Data { + // nolint:forcetypeassert + failures[entry.Resource.(string)] = entry.Message + } + + err := fmt.Errorf("one or more errors importing domain %ss: %+v", permType.String(), failures) + apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, err.Error()), m.processor.InstanceGetV1) + return + } + + // Success, return slice of newly-created domain perms. + domainPerms := make([]any, 0, multiStatus.Metadata.Success) + for _, entry := range multiStatus.Data { + domainPerms = append(domainPerms, entry.Resource) + } + + c.JSON(http.StatusOK, domainPerms) +} + +// deleteDomainPermission deletes a single domain permission (block or allow). +func (m *Module) deleteDomainPermission( + c *gin.Context, + permType gtsmodel.DomainPermissionType, // block/allow +) { + 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 _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + domainPermID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + domainPerm, _, errWithCode := m.processor.Admin().DomainPermissionDelete( + c.Request.Context(), + permType, + authed.Account, + domainPermID, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + c.JSON(http.StatusOK, domainPerm) +} + +// getDomainPermission gets a single domain permission (block or allow). +func (m *Module) getDomainPermission( + c *gin.Context, + permType gtsmodel.DomainPermissionType, +) { + 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 _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + domainPermID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + export, errWithCode := apiutil.ParseDomainPermissionExport(c.Query(apiutil.DomainPermissionExportKey), false) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + domainPerm, errWithCode := m.processor.Admin().DomainPermissionGet( + c.Request.Context(), + permType, + domainPermID, + export, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + c.JSON(http.StatusOK, domainPerm) +} + +// getDomainPermissions gets all domain permissions of the given type (block, allow). +func (m *Module) getDomainPermissions( + c *gin.Context, + permType gtsmodel.DomainPermissionType, +) { + 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 _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + export, errWithCode := apiutil.ParseDomainPermissionExport(c.Query(apiutil.DomainPermissionExportKey), false) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + domainPerm, errWithCode := m.processor.Admin().DomainPermissionsGet( + c.Request.Context(), + permType, + authed.Account, + export, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + c.JSON(http.StatusOK, domainPerm) +} diff --git a/internal/api/model/domain.go b/internal/api/model/domain.go index c5f77c82f..a5e1ddf10 100644 --- a/internal/api/model/domain.go +++ b/internal/api/model/domain.go @@ -37,46 +37,53 @@ type Domain struct { PublicComment string `form:"public_comment" json:"public_comment,omitempty"` } -// DomainBlock represents a block on one domain +// DomainPermission represents a permission applied to one domain (explicit block/allow). // -// swagger:model domainBlock -type DomainBlock struct { +// swagger:model domainPermission +type DomainPermission struct { Domain - // The ID of the domain block. + // The ID of the domain permission entry. // example: 01FBW21XJA09XYX51KV5JVBW0F // readonly: true ID string `json:"id,omitempty"` - // Obfuscate the domain name when serving this domain block publicly. - // A useful anti-harassment tool. + // Obfuscate the domain name when serving this domain permission entry publicly. // example: false Obfuscate bool `json:"obfuscate,omitempty"` - // Private comment for this block, visible to our instance admins only. + // Private comment for this permission entry, visible to this instance's admins only. // example: they are poopoo PrivateComment string `json:"private_comment,omitempty"` - // The ID of the subscription that created/caused this domain block. + // If applicable, the ID of the subscription that caused this domain permission entry to be created. // example: 01FBW25TF5J67JW3HFHZCSD23K SubscriptionID string `json:"subscription_id,omitempty"` - // ID of the account that created this domain block. + // ID of the account that created this domain permission entry. // example: 01FBW2758ZB6PBR200YPDDJK4C CreatedBy string `json:"created_by,omitempty"` - // Time at which this block was created (ISO 8601 Datetime). + // Time at which the permission entry was created (ISO 8601 Datetime). // example: 2021-07-30T09:20:25+00:00 CreatedAt string `json:"created_at,omitempty"` } -// DomainBlockCreateRequest is the form submitted as a POST to /api/v1/admin/domain_blocks to create a new block. +// DomainPermissionRequest is the form submitted as a POST to create a new domain permission entry (allow/block). // -// swagger:model domainBlockCreateRequest -type DomainBlockCreateRequest struct { - // A list of domains to block. Only used if import=true is specified. +// swagger:model domainPermissionCreateRequest +type DomainPermissionRequest struct { + // A list of domains for which this permission request should apply. + // Only used if import=true is specified. Domains *multipart.FileHeader `form:"domains" json:"domains" xml:"domains"` - // hostname/domain to block + // A single domain for which this permission request should apply. + // Only used if import=true is NOT specified or if import=false. + // example: example.org Domain string `form:"domain" json:"domain" xml:"domain"` - // whether the domain should be obfuscated when being displayed publicly + // Obfuscate the domain name when displaying this permission entry publicly. + // Ie., instead of 'example.org' show something like 'e**mpl*.or*'. + // example: false Obfuscate bool `form:"obfuscate" json:"obfuscate" xml:"obfuscate"` - // private comment for other admins on why the domain was blocked + // Private comment for other admins on why this permission entry was created. + // example: don't like 'em!!!! PrivateComment string `form:"private_comment" json:"private_comment" xml:"private_comment"` - // public comment on the reason for the domain block + // Public comment on why this permission entry was created. + // Will be visible to requesters at /api/v1/instance/peers if this endpoint is exposed. + // example: foss dorks 😫 PublicComment string `form:"public_comment" json:"public_comment" xml:"public_comment"` } diff --git a/internal/api/util/parsequery.go b/internal/api/util/parsequery.go index a87c77aeb..6a9116dcf 100644 --- a/internal/api/util/parsequery.go +++ b/internal/api/util/parsequery.go @@ -60,10 +60,10 @@ const ( WebUsernameKey = "username" WebStatusIDKey = "status" - /* Domain block keys */ + /* Domain permission keys */ - DomainBlockExportKey = "export" - DomainBlockImportKey = "import" + DomainPermissionExportKey = "export" + DomainPermissionImportKey = "import" ) // parseError returns gtserror.WithCode set to 400 Bad Request, to indicate @@ -121,12 +121,12 @@ func ParseSearchResolve(value string, defaultValue bool) (bool, gtserror.WithCod return parseBool(value, defaultValue, SearchResolveKey) } -func ParseDomainBlockExport(value string, defaultValue bool) (bool, gtserror.WithCode) { - return parseBool(value, defaultValue, DomainBlockExportKey) +func ParseDomainPermissionExport(value string, defaultValue bool) (bool, gtserror.WithCode) { + return parseBool(value, defaultValue, DomainPermissionExportKey) } -func ParseDomainBlockImport(value string, defaultValue bool) (bool, gtserror.WithCode) { - return parseBool(value, defaultValue, DomainBlockImportKey) +func ParseDomainPermissionImport(value string, defaultValue bool) (bool, gtserror.WithCode) { + return parseBool(value, defaultValue, DomainPermissionImportKey) } /* diff --git a/internal/cache/domain/domain.go b/internal/cache/domain/domain.go index 37e97472a..051ec5c1b 100644 --- a/internal/cache/domain/domain.go +++ b/internal/cache/domain/domain.go @@ -26,23 +26,28 @@ import ( "golang.org/x/exp/slices" ) -// BlockCache provides a means of caching domain blocks in memory to reduce load -// on an underlying storage mechanism, e.g. a database. +// Cache provides a means of caching domains in memory to reduce +// load on an underlying storage mechanism, e.g. a database. // -// The in-memory block list is kept up-to-date by means of a passed loader function during every -// call to .IsBlocked(). In the case of a nil internal block list, the loader function is called to -// hydrate the cache with the latest list of domain blocks. The .Clear() function can be used to -// invalidate the cache, e.g. when a domain block is added / deleted from the database. -type BlockCache struct { +// The in-memory domain list is kept up-to-date by means of a passed +// loader function during every call to .Matches(). In the case of +// a nil internal domain list, the loader function is called to hydrate +// the cache with the latest list of domains. +// +// The .Clear() function can be used to invalidate the cache, +// e.g. when an entry is added / deleted from the database. +type Cache struct { // atomically updated ptr value to the - // current domain block cache radix trie. + // current domain cache radix trie. rootptr unsafe.Pointer } -// IsBlocked checks whether domain is blocked. If the cache is not currently loaded, then the provided load function is used to hydrate it. -func (b *BlockCache) IsBlocked(domain string, load func() ([]string, error)) (bool, error) { +// Matches checks whether domain matches an entry in the cache. +// If the cache is not currently loaded, then the provided load +// function is used to hydrate it. +func (c *Cache) Matches(domain string, load func() ([]string, error)) (bool, error) { // Load the current root pointer value. - ptr := atomic.LoadPointer(&b.rootptr) + ptr := atomic.LoadPointer(&c.rootptr) if ptr == nil { // Cache is not hydrated. @@ -67,7 +72,7 @@ func (b *BlockCache) IsBlocked(domain string, load func() ([]string, error)) (bo // Store the new node ptr. ptr = unsafe.Pointer(root) - atomic.StorePointer(&b.rootptr, ptr) + atomic.StorePointer(&c.rootptr, ptr) } // Look for a match in the trie node. @@ -75,22 +80,20 @@ func (b *BlockCache) IsBlocked(domain string, load func() ([]string, error)) (bo } // Clear will drop the currently loaded domain list, -// triggering a reload on next call to .IsBlocked(). -func (b *BlockCache) Clear() { - atomic.StorePointer(&b.rootptr, nil) +// triggering a reload on next call to .Matches(). +func (c *Cache) Clear() { + atomic.StorePointer(&c.rootptr, nil) } -// String returns a string representation of stored domains in block cache. -func (b *BlockCache) String() string { - if ptr := atomic.LoadPointer(&b.rootptr); ptr != nil { +// String returns a string representation of stored domains in cache. +func (c *Cache) String() string { + if ptr := atomic.LoadPointer(&c.rootptr); ptr != nil { return (*root)(ptr).String() } return "<empty>" } -// root is the root node in the domain -// block cache radix trie. this is the -// singular access point to the trie. +// root is the root node in the domain cache radix trie. this is the singular access point to the trie. type root struct{ root node } // Add will add the given domain to the radix trie. @@ -99,14 +102,14 @@ func (r *root) Add(domain string) { } // Match will return whether the given domain matches -// an existing stored domain block in this radix trie. +// an existing stored domain in this radix trie. func (r *root) Match(domain string) bool { return r.root.match(strings.Split(domain, ".")) } // Sort will sort the entire radix trie ensuring that // child nodes are stored in alphabetical order. This -// MUST be done to finalize the block cache in order +// MUST be done to finalize the domain cache in order // to speed up the binary search of node child parts. func (r *root) Sort() { r.root.sort() @@ -154,7 +157,7 @@ func (n *node) add(parts []string) { if len(parts) == 0 { // Drop all children here as - // this is a higher-level block + // this is a higher-level domain // than that we previously had. nn.child = nil return diff --git a/internal/cache/domain/domain_test.go b/internal/cache/domain/domain_test.go index 8f975497b..9e091e1d0 100644 --- a/internal/cache/domain/domain_test.go +++ b/internal/cache/domain/domain_test.go @@ -24,21 +24,21 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/cache/domain" ) -func TestBlockCache(t *testing.T) { - c := new(domain.BlockCache) +func TestCache(t *testing.T) { + c := new(domain.Cache) - blocks := []string{ + cachedDomains := []string{ "google.com", "google.co.uk", "pleroma.bad.host", } loader := func() ([]string, error) { - t.Log("load: returning blocked domains") - return blocks, nil + t.Log("load: returning cached domains") + return cachedDomains, nil } - // Check a list of known blocked domains. + // Check a list of known cached domains. for _, domain := range []string{ "google.com", "mail.google.com", @@ -47,13 +47,13 @@ func TestBlockCache(t *testing.T) { "pleroma.bad.host", "dev.pleroma.bad.host", } { - t.Logf("checking domain is blocked: %s", domain) - if b, _ := c.IsBlocked(domain, loader); !b { - t.Errorf("domain should be blocked: %s", domain) + t.Logf("checking domain matches: %s", domain) + if b, _ := c.Matches(domain, loader); !b { + t.Errorf("domain should be matched: %s", domain) } } - // Check a list of known unblocked domains. + // Check a list of known uncached domains. for _, domain := range []string{ "askjeeves.com", "ask-kim.co.uk", @@ -62,9 +62,9 @@ func TestBlockCache(t *testing.T) { "gts.bad.host", "mastodon.bad.host", } { - t.Logf("checking domain isn't blocked: %s", domain) - if b, _ := c.IsBlocked(domain, loader); b { - t.Errorf("domain should not be blocked: %s", domain) + t.Logf("checking domain isn't matched: %s", domain) + if b, _ := c.Matches(domain, loader); b { + t.Errorf("domain should not be matched: %s", domain) } } @@ -76,10 +76,10 @@ func TestBlockCache(t *testing.T) { knownErr := errors.New("known error") // Check that reload is actually performed and returns our error - if _, err := c.IsBlocked("", func() ([]string, error) { + if _, err := c.Matches("", func() ([]string, error) { t.Log("load: returning known error") return nil, knownErr }); !errors.Is(err, knownErr) { - t.Errorf("is blocked did not return expected error: %v", err) + t.Errorf("matches did not return expected error: %v", err) } } diff --git a/internal/cache/gts.go b/internal/cache/gts.go index 12e917919..16a1585f7 100644 --- a/internal/cache/gts.go +++ b/internal/cache/gts.go @@ -36,7 +36,8 @@ type GTSCaches struct { block *result.Cache[*gtsmodel.Block] blockIDs *SliceCache[string] boostOfIDs *SliceCache[string] - domainBlock *domain.BlockCache + domainAllow *domain.Cache + domainBlock *domain.Cache emoji *result.Cache[*gtsmodel.Emoji] emojiCategory *result.Cache[*gtsmodel.EmojiCategory] follow *result.Cache[*gtsmodel.Follow] @@ -72,6 +73,7 @@ func (c *GTSCaches) Init() { c.initBlock() c.initBlockIDs() c.initBoostOfIDs() + c.initDomainAllow() c.initDomainBlock() c.initEmoji() c.initEmojiCategory() @@ -139,8 +141,13 @@ func (c *GTSCaches) BoostOfIDs() *SliceCache[string] { return c.boostOfIDs } +// DomainAllow provides access to the domain allow database cache. +func (c *GTSCaches) DomainAllow() *domain.Cache { + return c.domainAllow +} + // DomainBlock provides access to the domain block database cache. -func (c *GTSCaches) DomainBlock() *domain.BlockCache { +func (c *GTSCaches) DomainBlock() *domain.Cache { return c.domainBlock } @@ -384,8 +391,12 @@ func (c *GTSCaches) initBoostOfIDs() { )} } +func (c *GTSCaches) initDomainAllow() { + c.domainAllow = new(domain.Cache) +} + func (c *GTSCaches) initDomainBlock() { - c.domainBlock = new(domain.BlockCache) + c.domainBlock = new(domain.Cache) } func (c *GTSCaches) initEmoji() { diff --git a/internal/config/config.go b/internal/config/config.go index 16ef32a8b..314257831 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -76,12 +76,13 @@ type Configuration struct { WebTemplateBaseDir string `name:"web-template-base-dir" usage:"Basedir for html templating files for rendering pages and composing emails."` WebAssetBaseDir string `name:"web-asset-base-dir" usage:"Directory to serve static assets from, accessible at example.org/assets/"` - InstanceExposePeers bool `name:"instance-expose-peers" usage:"Allow unauthenticated users to query /api/v1/instance/peers?filter=open"` - InstanceExposeSuspended bool `name:"instance-expose-suspended" usage:"Expose suspended instances via web UI, and allow unauthenticated users to query /api/v1/instance/peers?filter=suspended"` - InstanceExposeSuspendedWeb bool `name:"instance-expose-suspended-web" usage:"Expose list of suspended instances as webpage on /about/suspended"` - InstanceExposePublicTimeline bool `name:"instance-expose-public-timeline" usage:"Allow unauthenticated users to query /api/v1/timelines/public"` - InstanceDeliverToSharedInboxes bool `name:"instance-deliver-to-shared-inboxes" usage:"Deliver federated messages to shared inboxes, if they're available."` - InstanceInjectMastodonVersion bool `name:"instance-inject-mastodon-version" usage:"This injects a Mastodon compatible version in /api/v1/instance to help Mastodon clients that use that version for feature detection"` + InstanceFederationMode string `name:"instance-federation-mode" usage:"Set instance federation mode."` + InstanceExposePeers bool `name:"instance-expose-peers" usage:"Allow unauthenticated users to query /api/v1/instance/peers?filter=open"` + InstanceExposeSuspended bool `name:"instance-expose-suspended" usage:"Expose suspended instances via web UI, and allow unauthenticated users to query /api/v1/instance/peers?filter=suspended"` + InstanceExposeSuspendedWeb bool `name:"instance-expose-suspended-web" usage:"Expose list of suspended instances as webpage on /about/suspended"` + InstanceExposePublicTimeline bool `name:"instance-expose-public-timeline" usage:"Allow unauthenticated users to query /api/v1/timelines/public"` + InstanceDeliverToSharedInboxes bool `name:"instance-deliver-to-shared-inboxes" usage:"Deliver federated messages to shared inboxes, if they're available."` + InstanceInjectMastodonVersion bool `name:"instance-inject-mastodon-version" usage:"This injects a Mastodon compatible version in /api/v1/instance to help Mastodon clients that use that version for feature detection"` AccountsRegistrationOpen bool `name:"accounts-registration-open" usage:"Allow anyone to submit an account signup request. If false, server will be invite-only."` AccountsApprovalRequired bool `name:"accounts-approval-required" usage:"Do account signups require approval by an admin or moderator before user can log in? If false, new registrations will be automatically approved."` diff --git a/internal/config/const.go b/internal/config/const.go new file mode 100644 index 000000000..29e4b14e8 --- /dev/null +++ b/internal/config/const.go @@ -0,0 +1,26 @@ +// 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 config + +// Instance federation mode determines how this +// instance federates with others (if at all). +const ( + InstanceFederationModeBlocklist = "blocklist" + InstanceFederationModeAllowlist = "allowlist" + InstanceFederationModeDefault = InstanceFederationModeBlocklist +) diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 9ad9c125c..fe2aa3acc 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -57,6 +57,7 @@ var Defaults = Configuration{ WebTemplateBaseDir: "./web/template/", WebAssetBaseDir: "./web/assets/", + InstanceFederationMode: InstanceFederationModeDefault, InstanceExposePeers: false, InstanceExposeSuspended: false, InstanceExposeSuspendedWeb: false, diff --git a/internal/config/flags.go b/internal/config/flags.go index 74ceedc00..29e0726a6 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -83,6 +83,7 @@ func (s *ConfigState) AddServerFlags(cmd *cobra.Command) { cmd.Flags().String(WebAssetBaseDirFlag(), cfg.WebAssetBaseDir, fieldtag("WebAssetBaseDir", "usage")) // Instance + cmd.Flags().String(InstanceFederationModeFlag(), cfg.InstanceFederationMode, fieldtag("InstanceFederationMode", "usage")) cmd.Flags().Bool(InstanceExposePeersFlag(), cfg.InstanceExposePeers, fieldtag("InstanceExposePeers", "usage")) cmd.Flags().Bool(InstanceExposeSuspendedFlag(), cfg.InstanceExposeSuspended, fieldtag("InstanceExposeSuspended", "usage")) cmd.Flags().Bool(InstanceExposeSuspendedWebFlag(), cfg.InstanceExposeSuspendedWeb, fieldtag("InstanceExposeSuspendedWeb", "usage")) diff --git a/internal/config/helpers.gen.go b/internal/config/helpers.gen.go index f232d37a3..46a239596 100644 --- a/internal/config/helpers.gen.go +++ b/internal/config/helpers.gen.go @@ -749,6 +749,31 @@ func GetWebAssetBaseDir() string { return global.GetWebAssetBaseDir() } // SetWebAssetBaseDir safely sets the value for global configuration 'WebAssetBaseDir' field func SetWebAssetBaseDir(v string) { global.SetWebAssetBaseDir(v) } +// GetInstanceFederationMode safely fetches the Configuration value for state's 'InstanceFederationMode' field +func (st *ConfigState) GetInstanceFederationMode() (v string) { + st.mutex.RLock() + v = st.config.InstanceFederationMode + st.mutex.RUnlock() + return +} + +// SetInstanceFederationMode safely sets the Configuration value for state's 'InstanceFederationMode' field +func (st *ConfigState) SetInstanceFederationMode(v string) { + st.mutex.Lock() + defer st.mutex.Unlock() + st.config.InstanceFederationMode = v + st.reloadToViper() +} + +// InstanceFederationModeFlag returns the flag name for the 'InstanceFederationMode' field +func InstanceFederationModeFlag() string { return "instance-federation-mode" } + +// GetInstanceFederationMode safely fetches the value for global configuration 'InstanceFederationMode' field +func GetInstanceFederationMode() string { return global.GetInstanceFederationMode() } + +// SetInstanceFederationMode safely sets the value for global configuration 'InstanceFederationMode' field +func SetInstanceFederationMode(v string) { global.SetInstanceFederationMode(v) } + // GetInstanceExposePeers safely fetches the Configuration value for state's 'InstanceExposePeers' field func (st *ConfigState) GetInstanceExposePeers() (v bool) { st.mutex.RLock() diff --git a/internal/config/validate.go b/internal/config/validate.go index bc8edc816..45cdc4eee 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -61,6 +61,17 @@ func Validate() error { errs = append(errs, fmt.Errorf("%s must be set to either http or https, provided value was %s", ProtocolFlag(), proto)) } + // federation mode + switch federationMode := GetInstanceFederationMode(); federationMode { + case InstanceFederationModeBlocklist, InstanceFederationModeAllowlist: + // no problem + break + case "": + errs = append(errs, fmt.Errorf("%s must be set", InstanceFederationModeFlag())) + default: + errs = append(errs, fmt.Errorf("%s must be set to either blocklist or allowlist, provided value was %s", InstanceFederationModeFlag(), federationMode)) + } + webAssetsBaseDir := GetWebAssetBaseDir() if webAssetsBaseDir == "" { errs = append(errs, fmt.Errorf("%s must be set", WebAssetBaseDirFlag())) diff --git a/internal/db/bundb/domain.go b/internal/db/bundb/domain.go index c989d4fe4..dd626bc0a 100644 --- a/internal/db/bundb/domain.go +++ b/internal/db/bundb/domain.go @@ -23,6 +23,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/util" @@ -34,6 +35,102 @@ type domainDB struct { state *state.State } +func (d *domainDB) CreateDomainAllow(ctx context.Context, allow *gtsmodel.DomainAllow) error { + // Normalize the domain as punycode + var err error + allow.Domain, err = util.Punify(allow.Domain) + if err != nil { + return err + } + + // Attempt to store domain allow in DB + if _, err := d.db.NewInsert(). + Model(allow). + Exec(ctx); err != nil { + return err + } + + // Clear the domain allow cache (for later reload) + d.state.Caches.GTS.DomainAllow().Clear() + + return nil +} + +func (d *domainDB) GetDomainAllow(ctx context.Context, domain string) (*gtsmodel.DomainAllow, error) { + // Normalize the domain as punycode + domain, err := util.Punify(domain) + if err != nil { + return nil, err + } + + // Check for easy case, domain referencing *us* + if domain == "" || domain == config.GetAccountDomain() || + domain == config.GetHost() { + return nil, db.ErrNoEntries + } + + var allow gtsmodel.DomainAllow + + // Look for allow matching domain in DB + q := d.db. + NewSelect(). + Model(&allow). + Where("? = ?", bun.Ident("domain_allow.domain"), domain) + if err := q.Scan(ctx); err != nil { + return nil, err + } + + return &allow, nil +} + +func (d *domainDB) GetDomainAllows(ctx context.Context) ([]*gtsmodel.DomainAllow, error) { + allows := []*gtsmodel.DomainAllow{} + + if err := d.db. + NewSelect(). + Model(&allows). + Scan(ctx); err != nil { + return nil, err + } + + return allows, nil +} + +func (d *domainDB) GetDomainAllowByID(ctx context.Context, id string) (*gtsmodel.DomainAllow, error) { + var allow gtsmodel.DomainAllow + + q := d.db. + NewSelect(). + Model(&allow). + Where("? = ?", bun.Ident("domain_allow.id"), id) + if err := q.Scan(ctx); err != nil { + return nil, err + } + + return &allow, nil +} + +func (d *domainDB) DeleteDomainAllow(ctx context.Context, domain string) error { + // Normalize the domain as punycode + domain, err := util.Punify(domain) + if err != nil { + return err + } + + // Attempt to delete domain allow + if _, err := d.db.NewDelete(). + Model((*gtsmodel.DomainAllow)(nil)). + Where("? = ?", bun.Ident("domain_allow.domain"), domain). + Exec(ctx); err != nil { + return err + } + + // Clear the domain allow cache (for later reload) + d.state.Caches.GTS.DomainAllow().Clear() + + return nil +} + func (d *domainDB) CreateDomainBlock(ctx context.Context, block *gtsmodel.DomainBlock) error { // Normalize the domain as punycode var err error @@ -137,14 +234,32 @@ func (d *domainDB) IsDomainBlocked(ctx context.Context, domain string) (bool, er return false, err } - // Check for easy case, domain referencing *us* + // Domain referencing *us* cannot be blocked. if domain == "" || domain == config.GetAccountDomain() || domain == config.GetHost() { return false, nil } + // Check the cache for an explicit domain allow (hydrating the cache with callback if necessary). + explicitAllow, err := d.state.Caches.GTS.DomainAllow().Matches(domain, func() ([]string, error) { + var domains []string + + // Scan list of all explicitly allowed domains from DB + q := d.db.NewSelect(). + Table("domain_allows"). + Column("domain") + if err := q.Scan(ctx, &domains); err != nil { + return nil, err + } + + return domains, nil + }) + if err != nil { + return false, err + } + // Check the cache for a domain block (hydrating the cache with callback if necessary) - return d.state.Caches.GTS.DomainBlock().IsBlocked(domain, func() ([]string, error) { + explicitBlock, err := d.state.Caches.GTS.DomainBlock().Matches(domain, func() ([]string, error) { var domains []string // Scan list of all blocked domains from DB @@ -157,6 +272,35 @@ func (d *domainDB) IsDomainBlocked(ctx context.Context, domain string) (bool, er return domains, nil }) + if err != nil { + return false, err + } + + // Calculate if blocked + // based on federation mode. + switch mode := config.GetInstanceFederationMode(); mode { + + case config.InstanceFederationModeBlocklist: + // Blocklist/default mode: explicit allow + // takes precedence over explicit block. + // + // Domains that have neither block + // or allow entries are allowed. + return !(explicitAllow || !explicitBlock), nil + + case config.InstanceFederationModeAllowlist: + // Allowlist mode: explicit block takes + // precedence over explicit allow. + // + // Domains that have neither block + // or allow entries are blocked. + return (explicitBlock || !explicitAllow), nil + + default: + // This should never happen but account + // for it anyway to make the code tidier. + return false, gtserror.Newf("unrecognized federation mode: %s", mode) + } } func (d *domainDB) AreDomainsBlocked(ctx context.Context, domains []string) (bool, error) { diff --git a/internal/db/bundb/domain_test.go b/internal/db/bundb/domain_test.go index e4e199fa1..ff687cf59 100644 --- a/internal/db/bundb/domain_test.go +++ b/internal/db/bundb/domain_test.go @@ -55,6 +55,59 @@ func (suite *DomainTestSuite) TestIsDomainBlocked() { suite.WithinDuration(time.Now(), domainBlock.CreatedAt, 10*time.Second) } +func (suite *DomainTestSuite) TestIsDomainBlockedWithAllow() { + ctx := context.Background() + + domainBlock := >smodel.DomainBlock{ + ID: "01G204214Y9TNJEBX39C7G88SW", + Domain: "some.bad.apples", + CreatedByAccountID: suite.testAccounts["admin_account"].ID, + CreatedByAccount: suite.testAccounts["admin_account"], + } + + // no domain block exists for the given domain yet + blocked, err := suite.db.IsDomainBlocked(ctx, domainBlock.Domain) + if err != nil { + suite.FailNow(err.Error()) + } + + suite.False(blocked) + + // Block this domain. + if err := suite.db.CreateDomainBlock(ctx, domainBlock); err != nil { + suite.FailNow(err.Error()) + } + + // domain block now exists + blocked, err = suite.db.IsDomainBlocked(ctx, domainBlock.Domain) + if err != nil { + suite.FailNow(err.Error()) + } + + suite.True(blocked) + suite.WithinDuration(time.Now(), domainBlock.CreatedAt, 10*time.Second) + + // Explicitly allow this domain. + domainAllow := >smodel.DomainAllow{ + ID: "01H8KY9MJQFWE712EG3VN02Y3J", + Domain: "some.bad.apples", + CreatedByAccountID: suite.testAccounts["admin_account"].ID, + CreatedByAccount: suite.testAccounts["admin_account"], + } + + if err := suite.db.CreateDomainAllow(ctx, domainAllow); err != nil { + suite.FailNow(err.Error()) + } + + // Domain allow now exists + blocked, err = suite.db.IsDomainBlocked(ctx, domainBlock.Domain) + if err != nil { + suite.FailNow(err.Error()) + } + + suite.False(blocked) +} + func (suite *DomainTestSuite) TestIsDomainBlockedWildcard() { ctx := context.Background() diff --git a/internal/db/bundb/migrations/20230908083121_allowlist.go.go b/internal/db/bundb/migrations/20230908083121_allowlist.go.go new file mode 100644 index 000000000..2d86f8c03 --- /dev/null +++ b/internal/db/bundb/migrations/20230908083121_allowlist.go.go @@ -0,0 +1,62 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package migrations + +import ( + "context" + + gtsmodel "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/uptrace/bun" +) + +func init() { + up := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + // Create domain allow. + if _, err := tx. + NewCreateTable(). + Model(>smodel.DomainAllow{}). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + + // Index domain allow. + if _, err := tx. + NewCreateIndex(). + Table("domain_allows"). + Index("domain_allows_domain_idx"). + Column("domain"). + Exec(ctx); err != nil { + return err + } + + return nil + }) + } + + down := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + return nil + }) + } + + if err := Migrations.Register(up, down); err != nil { + panic(err) + } +} diff --git a/internal/db/domain.go b/internal/db/domain.go index 740ccefe6..3f7803d62 100644 --- a/internal/db/domain.go +++ b/internal/db/domain.go @@ -26,6 +26,25 @@ import ( // Domain contains DB functions related to domains and domain blocks. type Domain interface { + /* + Block/allow storage + retrieval functions. + */ + + // CreateDomainAllow puts the given instance-level domain allow into the database. + CreateDomainAllow(ctx context.Context, allow *gtsmodel.DomainAllow) error + + // GetDomainAllow returns one instance-level domain allow with the given domain, if it exists. + GetDomainAllow(ctx context.Context, domain string) (*gtsmodel.DomainAllow, error) + + // GetDomainAllowByID returns one instance-level domain allow with the given id, if it exists. + GetDomainAllowByID(ctx context.Context, id string) (*gtsmodel.DomainAllow, error) + + // GetDomainAllows returns all instance-level domain allows currently enforced by this instance. + GetDomainAllows(ctx context.Context) ([]*gtsmodel.DomainAllow, error) + + // DeleteDomainAllow deletes an instance-level domain allow with the given domain, if it exists. + DeleteDomainAllow(ctx context.Context, domain string) error + // CreateDomainBlock puts the given instance-level domain block into the database. CreateDomainBlock(ctx context.Context, block *gtsmodel.DomainBlock) error @@ -41,15 +60,22 @@ type Domain interface { // DeleteDomainBlock deletes an instance-level domain block with the given domain, if it exists. DeleteDomainBlock(ctx context.Context, domain string) error - // IsDomainBlocked checks if an instance-level domain block exists for the given domain string (eg., `example.org`). + /* + Block/allow checking functions. + */ + + // IsDomainBlocked checks if domain is blocked, accounting for both explicit allows and blocks. + // Will check allows first, so an allowed domain will always return false, even if it's also blocked. IsDomainBlocked(ctx context.Context, domain string) (bool, error) - // AreDomainsBlocked checks if an instance-level domain block exists for any of the given domains strings, and returns true if even one is found. + // AreDomainsBlocked calls IsDomainBlocked for each domain. + // Will return true if even one of the given domains is blocked. AreDomainsBlocked(ctx context.Context, domains []string) (bool, error) - // IsURIBlocked checks if an instance-level domain block exists for the `host` in the given URI (eg., `https://example.org/users/whatever`). + // IsURIBlocked calls IsDomainBlocked for the host of the given URI. IsURIBlocked(ctx context.Context, uri *url.URL) (bool, error) - // AreURIsBlocked checks if an instance-level domain block exists for any `host` in the given URI slice, and returns true if even one is found. + // AreURIsBlocked calls IsURIBlocked for each URI. + // Will return true if even one of the given URIs is blocked. AreURIsBlocked(ctx context.Context, uris []*url.URL) (bool, error) } diff --git a/internal/gtsmodel/adminaction.go b/internal/gtsmodel/adminaction.go index 1e55a33f9..e8b82e495 100644 --- a/internal/gtsmodel/adminaction.go +++ b/internal/gtsmodel/adminaction.go @@ -42,7 +42,7 @@ func (c AdminActionCategory) String() string { case AdminActionCategoryDomain: return "domain" default: - return "unknown" + return "unknown" //nolint:goconst } } diff --git a/internal/gtsmodel/domainallow.go b/internal/gtsmodel/domainallow.go new file mode 100644 index 000000000..2a3e53e79 --- /dev/null +++ b/internal/gtsmodel/domainallow.go @@ -0,0 +1,78 @@ +// 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 gtsmodel + +import "time" + +// DomainAllow represents a federation allow towards a particular domain. +type DomainAllow struct { + ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database + CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created + UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated + Domain string `bun:",nullzero,notnull"` // domain to allow. Eg. 'whatever.com' + CreatedByAccountID string `bun:"type:CHAR(26),nullzero,notnull"` // Account ID of the creator of this allow + CreatedByAccount *Account `bun:"rel:belongs-to"` // Account corresponding to createdByAccountID + PrivateComment string `bun:""` // Private comment on this allow, viewable to admins + PublicComment string `bun:""` // Public comment on this allow, viewable (optionally) by everyone + Obfuscate *bool `bun:",nullzero,notnull,default:false"` // whether the domain name should appear obfuscated when displaying it publicly + SubscriptionID string `bun:"type:CHAR(26),nullzero"` // if this allow was created through a subscription, what's the subscription ID? +} + +func (d *DomainAllow) GetID() string { + return d.ID +} + +func (d *DomainAllow) GetCreatedAt() time.Time { + return d.CreatedAt +} + +func (d *DomainAllow) GetUpdatedAt() time.Time { + return d.UpdatedAt +} + +func (d *DomainAllow) GetDomain() string { + return d.Domain +} + +func (d *DomainAllow) GetCreatedByAccountID() string { + return d.CreatedByAccountID +} + +func (d *DomainAllow) GetCreatedByAccount() *Account { + return d.CreatedByAccount +} + +func (d *DomainAllow) GetPrivateComment() string { + return d.PrivateComment +} + +func (d *DomainAllow) GetPublicComment() string { + return d.PublicComment +} + +func (d *DomainAllow) GetObfuscate() *bool { + return d.Obfuscate +} + +func (d *DomainAllow) GetSubscriptionID() string { + return d.SubscriptionID +} + +func (d *DomainAllow) GetType() DomainPermissionType { + return DomainPermissionAllow +} diff --git a/internal/gtsmodel/domainblock.go b/internal/gtsmodel/domainblock.go index dfe642ef5..4e0b3ca65 100644 --- a/internal/gtsmodel/domainblock.go +++ b/internal/gtsmodel/domainblock.go @@ -32,3 +32,47 @@ type DomainBlock struct { Obfuscate *bool `bun:",nullzero,notnull,default:false"` // whether the domain name should appear obfuscated when displaying it publicly SubscriptionID string `bun:"type:CHAR(26),nullzero"` // if this block was created through a subscription, what's the subscription ID? } + +func (d *DomainBlock) GetID() string { + return d.ID +} + +func (d *DomainBlock) GetCreatedAt() time.Time { + return d.CreatedAt +} + +func (d *DomainBlock) GetUpdatedAt() time.Time { + return d.UpdatedAt +} + +func (d *DomainBlock) GetDomain() string { + return d.Domain +} + +func (d *DomainBlock) GetCreatedByAccountID() string { + return d.CreatedByAccountID +} + +func (d *DomainBlock) GetCreatedByAccount() *Account { + return d.CreatedByAccount +} + +func (d *DomainBlock) GetPrivateComment() string { + return d.PrivateComment +} + +func (d *DomainBlock) GetPublicComment() string { + return d.PublicComment +} + +func (d *DomainBlock) GetObfuscate() *bool { + return d.Obfuscate +} + +func (d *DomainBlock) GetSubscriptionID() string { + return d.SubscriptionID +} + +func (d *DomainBlock) GetType() DomainPermissionType { + return DomainPermissionBlock +} diff --git a/internal/gtsmodel/domainpermission.go b/internal/gtsmodel/domainpermission.go new file mode 100644 index 000000000..01e8fdaaa --- /dev/null +++ b/internal/gtsmodel/domainpermission.go @@ -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 gtsmodel + +import "time" + +// DomainPermission models a domain +// permission entry (block/allow). +type DomainPermission interface { + GetID() string + GetCreatedAt() time.Time + GetUpdatedAt() time.Time + GetDomain() string + GetCreatedByAccountID() string + GetCreatedByAccount() *Account + GetPrivateComment() string + GetPublicComment() string + GetObfuscate() *bool + GetSubscriptionID() string + GetType() DomainPermissionType +} + +// Domain permission type. +type DomainPermissionType uint8 + +const ( + DomainPermissionUnknown DomainPermissionType = iota + DomainPermissionBlock // Explicitly block a domain. + DomainPermissionAllow // Explicitly allow a domain. +) + +func (p DomainPermissionType) String() string { + switch p { + case DomainPermissionBlock: + return "block" + case DomainPermissionAllow: + return "allow" + default: + return "unknown" + } +} + +func NewDomainPermissionType(in string) DomainPermissionType { + switch in { + case "block": + return DomainPermissionBlock + case "allow": + return DomainPermissionAllow + default: + return DomainPermissionUnknown + } +} diff --git a/internal/processing/admin/domainallow.go b/internal/processing/admin/domainallow.go new file mode 100644 index 000000000..bab54e308 --- /dev/null +++ b/internal/processing/admin/domainallow.go @@ -0,0 +1,255 @@ +// 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" + + "codeberg.org/gruf/go-kv" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/text" +) + +func (p *Processor) createDomainAllow( + ctx context.Context, + adminAcct *gtsmodel.Account, + domain string, + obfuscate bool, + publicComment string, + privateComment string, + subscriptionID string, +) (*apimodel.DomainPermission, string, gtserror.WithCode) { + // Check if an allow already exists for this domain. + domainAllow, err := p.state.DB.GetDomainAllow(ctx, domain) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + // Something went wrong in the DB. + err = gtserror.Newf("db error getting domain allow %s: %w", domain, err) + return nil, "", gtserror.NewErrorInternalError(err) + } + + if domainAllow == nil { + // No allow exists yet, create it. + domainAllow = >smodel.DomainAllow{ + ID: id.NewULID(), + Domain: domain, + CreatedByAccountID: adminAcct.ID, + PrivateComment: text.SanitizeToPlaintext(privateComment), + PublicComment: text.SanitizeToPlaintext(publicComment), + Obfuscate: &obfuscate, + SubscriptionID: subscriptionID, + } + + // Insert the new allow into the database. + if err := p.state.DB.CreateDomainAllow(ctx, domainAllow); err != nil { + err = gtserror.Newf("db error putting domain allow %s: %w", domain, err) + return nil, "", gtserror.NewErrorInternalError(err) + } + } + + actionID := id.NewULID() + + // Process domain allow side + // effects asynchronously. + if errWithCode := p.actions.Run( + ctx, + >smodel.AdminAction{ + ID: actionID, + TargetCategory: gtsmodel.AdminActionCategoryDomain, + TargetID: domain, + Type: gtsmodel.AdminActionSuspend, + AccountID: adminAcct.ID, + Text: domainAllow.PrivateComment, + }, + func(ctx context.Context) gtserror.MultiError { + // Log start + finish. + l := log.WithFields(kv.Fields{ + {"domain", domain}, + {"actionID", actionID}, + }...).WithContext(ctx) + + l.Info("processing domain allow side effects") + defer func() { l.Info("finished processing domain allow side effects") }() + + return p.domainAllowSideEffects(ctx, domainAllow) + }, + ); errWithCode != nil { + return nil, actionID, errWithCode + } + + apiDomainAllow, errWithCode := p.apiDomainPerm(ctx, domainAllow, false) + if errWithCode != nil { + return nil, actionID, errWithCode + } + + return apiDomainAllow, actionID, nil +} + +func (p *Processor) domainAllowSideEffects( + ctx context.Context, + allow *gtsmodel.DomainAllow, +) gtserror.MultiError { + if config.GetInstanceFederationMode() == config.InstanceFederationModeAllowlist { + // We're running in allowlist mode, + // so there are no side effects to + // process here. + return nil + } + + // We're running in blocklist mode or + // some similar mode which necessitates + // domain allow side effects if a block + // was in place when the allow was created. + // + // So, check if there's a block. + block, err := p.state.DB.GetDomainBlock(ctx, allow.Domain) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + errs := gtserror.NewMultiError(1) + errs.Appendf("db error getting domain block %s: %w", allow.Domain, err) + return errs + } + + if block == nil { + // No block? + // No problem! + return nil + } + + // There was a block, over which the new + // allow ought to take precedence. To account + // for this, just run side effects as though + // the domain was being unblocked, while + // leaving the existing block in place. + // + // Any accounts that were suspended by + // the block will be unsuspended and be + // able to interact with the instance again. + return p.domainUnblockSideEffects(ctx, block) +} + +func (p *Processor) deleteDomainAllow( + ctx context.Context, + adminAcct *gtsmodel.Account, + domainAllowID string, +) (*apimodel.DomainPermission, string, gtserror.WithCode) { + domainAllow, err := p.state.DB.GetDomainAllowByID(ctx, domainAllowID) + if err != nil { + if !errors.Is(err, db.ErrNoEntries) { + // Real error. + err = gtserror.Newf("db error getting domain allow: %w", err) + return nil, "", gtserror.NewErrorInternalError(err) + } + + // There are just no entries for this ID. + err = fmt.Errorf("no domain allow entry exists with ID %s", domainAllowID) + return nil, "", gtserror.NewErrorNotFound(err, err.Error()) + } + + // Prepare the domain allow to return, *before* the deletion goes through. + apiDomainAllow, errWithCode := p.apiDomainPerm(ctx, domainAllow, false) + if errWithCode != nil { + return nil, "", errWithCode + } + + // Delete the original domain allow. + if err := p.state.DB.DeleteDomainAllow(ctx, domainAllow.Domain); err != nil { + err = gtserror.Newf("db error deleting domain allow: %w", err) + return nil, "", gtserror.NewErrorInternalError(err) + } + + actionID := id.NewULID() + + // Process domain unallow side + // effects asynchronously. + if errWithCode := p.actions.Run( + ctx, + >smodel.AdminAction{ + ID: actionID, + TargetCategory: gtsmodel.AdminActionCategoryDomain, + TargetID: domainAllow.Domain, + Type: gtsmodel.AdminActionUnsuspend, + AccountID: adminAcct.ID, + }, + func(ctx context.Context) gtserror.MultiError { + // Log start + finish. + l := log.WithFields(kv.Fields{ + {"domain", domainAllow.Domain}, + {"actionID", actionID}, + }...).WithContext(ctx) + + l.Info("processing domain unallow side effects") + defer func() { l.Info("finished processing domain unallow side effects") }() + + return p.domainUnallowSideEffects(ctx, domainAllow) + }, + ); errWithCode != nil { + return nil, actionID, errWithCode + } + + return apiDomainAllow, actionID, nil +} + +func (p *Processor) domainUnallowSideEffects( + ctx context.Context, + allow *gtsmodel.DomainAllow, +) gtserror.MultiError { + if config.GetInstanceFederationMode() == config.InstanceFederationModeAllowlist { + // We're running in allowlist mode, + // so there are no side effects to + // process here. + return nil + } + + // We're running in blocklist mode or + // some similar mode which necessitates + // domain allow side effects if a block + // was in place when the allow was removed. + // + // So, check if there's a block. + block, err := p.state.DB.GetDomainBlock(ctx, allow.Domain) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + errs := gtserror.NewMultiError(1) + errs.Appendf("db error getting domain block %s: %w", allow.Domain, err) + return errs + } + + if block == nil { + // No block? + // No problem! + return nil + } + + // There was a block, over which the previous + // allow was taking precedence. Now that the + // allow has been removed, we should put the + // side effects of the block back in place. + // + // To do this, process the block side effects + // again as though the block were freshly + // created. This will mark all accounts from + // the blocked domain as suspended, and clean + // up their follows/following, media, etc. + return p.domainBlockSideEffects(ctx, block) +} diff --git a/internal/processing/admin/domainblock.go b/internal/processing/admin/domainblock.go index 1262bf6b0..4161ec12f 100644 --- a/internal/processing/admin/domainblock.go +++ b/internal/processing/admin/domainblock.go @@ -18,14 +18,9 @@ package admin import ( - "bytes" "context" - "encoding/json" "errors" "fmt" - "io" - "mime/multipart" - "net/http" "time" "codeberg.org/gruf/go-kv" @@ -40,14 +35,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/text" ) -// DomainBlockCreate creates an instance-level block against the given domain, -// and then processes side effects of that block (deleting accounts, media, etc). -// -// If a domain block already exists for the domain, side effects will be retried. -// -// Return values for this function are the (new) domain block, the ID of the admin -// action resulting from this call, and/or an error if something goes wrong. -func (p *Processor) DomainBlockCreate( +func (p *Processor) createDomainBlock( ctx context.Context, adminAcct *gtsmodel.Account, domain string, @@ -55,7 +43,7 @@ func (p *Processor) DomainBlockCreate( publicComment string, privateComment string, subscriptionID string, -) (*apimodel.DomainBlock, string, gtserror.WithCode) { +) (*apimodel.DomainPermission, string, gtserror.WithCode) { // Check if a block already exists for this domain. domainBlock, err := p.state.DB.GetDomainBlock(ctx, domain) if err != nil && !errors.Is(err, db.ErrNoEntries) { @@ -98,13 +86,22 @@ func (p *Processor) DomainBlockCreate( Text: domainBlock.PrivateComment, }, func(ctx context.Context) gtserror.MultiError { + // Log start + finish. + l := log.WithFields(kv.Fields{ + {"domain", domain}, + {"actionID", actionID}, + }...).WithContext(ctx) + + l.Info("processing domain block side effects") + defer func() { l.Info("finished processing domain block side effects") }() + return p.domainBlockSideEffects(ctx, domainBlock) }, ); errWithCode != nil { return nil, actionID, errWithCode } - apiDomainBlock, errWithCode := p.apiDomainBlock(ctx, domainBlock) + apiDomainBlock, errWithCode := p.apiDomainPerm(ctx, domainBlock, false) if errWithCode != nil { return nil, actionID, errWithCode } @@ -112,206 +109,6 @@ func (p *Processor) DomainBlockCreate( return apiDomainBlock, actionID, nil } -// DomainBlockDelete removes one domain block with the given ID, -// and processes side effects of removing the block asynchronously. -// -// Return values for this function are the deleted domain block, the ID of the admin -// action resulting from this call, and/or an error if something goes wrong. -func (p *Processor) DomainBlockDelete( - ctx context.Context, - adminAcct *gtsmodel.Account, - domainBlockID string, -) (*apimodel.DomainBlock, string, gtserror.WithCode) { - domainBlock, err := p.state.DB.GetDomainBlockByID(ctx, domainBlockID) - if err != nil { - if !errors.Is(err, db.ErrNoEntries) { - // Real error. - err = gtserror.Newf("db error getting domain block: %w", err) - return nil, "", gtserror.NewErrorInternalError(err) - } - - // There are just no entries for this ID. - err = fmt.Errorf("no domain block entry exists with ID %s", domainBlockID) - return nil, "", gtserror.NewErrorNotFound(err, err.Error()) - } - - // Prepare the domain block to return, *before* the deletion goes through. - apiDomainBlock, errWithCode := p.apiDomainBlock(ctx, domainBlock) - if errWithCode != nil { - return nil, "", errWithCode - } - - // Copy value of the domain block. - domainBlockC := new(gtsmodel.DomainBlock) - *domainBlockC = *domainBlock - - // Delete the original domain block. - if err := p.state.DB.DeleteDomainBlock(ctx, domainBlock.Domain); err != nil { - err = gtserror.Newf("db error deleting domain block: %w", err) - return nil, "", gtserror.NewErrorInternalError(err) - } - - actionID := id.NewULID() - - // Process domain unblock side - // effects asynchronously. - if errWithCode := p.actions.Run( - ctx, - >smodel.AdminAction{ - ID: actionID, - TargetCategory: gtsmodel.AdminActionCategoryDomain, - TargetID: domainBlockC.Domain, - Type: gtsmodel.AdminActionUnsuspend, - AccountID: adminAcct.ID, - }, - func(ctx context.Context) gtserror.MultiError { - return p.domainUnblockSideEffects(ctx, domainBlock) - }, - ); errWithCode != nil { - return nil, actionID, errWithCode - } - - return apiDomainBlock, actionID, nil -} - -// DomainBlocksImport handles the import of multiple domain blocks, -// by calling the DomainBlockCreate function for each domain in the -// provided file. Will return a slice of processed domain blocks. -// -// In the case of total failure, a gtserror.WithCode will be returned -// so that the caller can respond appropriately. In the case of -// partial or total success, a MultiStatus model will be returned, -// which contains information about success/failure count, so that -// the caller can retry any failures as they wish. -func (p *Processor) DomainBlocksImport( - ctx context.Context, - account *gtsmodel.Account, - domainsF *multipart.FileHeader, -) (*apimodel.MultiStatus, gtserror.WithCode) { - // Open the provided file. - file, err := domainsF.Open() - if err != nil { - err = gtserror.Newf("error opening attachment: %w", err) - return nil, gtserror.NewErrorBadRequest(err, err.Error()) - } - defer file.Close() - - // Copy the file contents into a buffer. - buf := new(bytes.Buffer) - size, err := io.Copy(buf, file) - if err != nil { - err = gtserror.Newf("error reading attachment: %w", err) - return nil, gtserror.NewErrorBadRequest(err, err.Error()) - } - - // Ensure we actually read something. - if size == 0 { - err = gtserror.New("error reading attachment: size 0 bytes") - return nil, gtserror.NewErrorBadRequest(err, err.Error()) - } - - // Parse bytes as slice of domain blocks. - domainBlocks := make([]*apimodel.DomainBlock, 0) - if err := json.Unmarshal(buf.Bytes(), &domainBlocks); err != nil { - err = gtserror.Newf("error parsing attachment as domain blocks: %w", err) - return nil, gtserror.NewErrorBadRequest(err, err.Error()) - } - - count := len(domainBlocks) - if count == 0 { - err = gtserror.New("error importing domain blocks: 0 entries provided") - return nil, gtserror.NewErrorBadRequest(err, err.Error()) - } - - // Try to process each domain block, differentiating - // between successes and errors so that the caller can - // try failed imports again if desired. - multiStatusEntries := make([]apimodel.MultiStatusEntry, 0, count) - - for _, domainBlock := range domainBlocks { - var ( - domain = domainBlock.Domain.Domain - obfuscate = domainBlock.Obfuscate - publicComment = domainBlock.PublicComment - privateComment = domainBlock.PrivateComment - subscriptionID = "" // No sub ID for imports. - errWithCode gtserror.WithCode - ) - - domainBlock, _, errWithCode = p.DomainBlockCreate( - ctx, - account, - domain, - obfuscate, - publicComment, - privateComment, - subscriptionID, - ) - - var entry *apimodel.MultiStatusEntry - - if errWithCode != nil { - entry = &apimodel.MultiStatusEntry{ - // Use the failed domain entry as the resource value. - Resource: domain, - Message: errWithCode.Safe(), - Status: errWithCode.Code(), - } - } else { - entry = &apimodel.MultiStatusEntry{ - // Use successfully created API model domain block as the resource value. - Resource: domainBlock, - Message: http.StatusText(http.StatusOK), - Status: http.StatusOK, - } - } - - multiStatusEntries = append(multiStatusEntries, *entry) - } - - return apimodel.NewMultiStatus(multiStatusEntries), nil -} - -// DomainBlocksGet returns all existing domain blocks. If export is -// true, the format will be suitable for writing out to an export. -func (p *Processor) DomainBlocksGet(ctx context.Context, account *gtsmodel.Account, export bool) ([]*apimodel.DomainBlock, gtserror.WithCode) { - domainBlocks, err := p.state.DB.GetDomainBlocks(ctx) - if err != nil && !errors.Is(err, db.ErrNoEntries) { - err = gtserror.Newf("db error getting domain blocks: %w", err) - return nil, gtserror.NewErrorInternalError(err) - } - - apiDomainBlocks := make([]*apimodel.DomainBlock, 0, len(domainBlocks)) - for _, domainBlock := range domainBlocks { - apiDomainBlock, errWithCode := p.apiDomainBlock(ctx, domainBlock) - if errWithCode != nil { - return nil, errWithCode - } - - apiDomainBlocks = append(apiDomainBlocks, apiDomainBlock) - } - - return apiDomainBlocks, nil -} - -// DomainBlockGet returns one domain block with the given id. If export -// is true, the format will be suitable for writing out to an export. -func (p *Processor) DomainBlockGet(ctx context.Context, id string, export bool) (*apimodel.DomainBlock, gtserror.WithCode) { - domainBlock, err := p.state.DB.GetDomainBlockByID(ctx, id) - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - err = fmt.Errorf("no domain block exists with id %s", id) - return nil, gtserror.NewErrorNotFound(err, err.Error()) - } - - // Something went wrong in the DB. - err = gtserror.Newf("db error getting domain block %s: %w", id, err) - return nil, gtserror.NewErrorInternalError(err) - } - - return p.apiDomainBlock(ctx, domainBlock) -} - // domainBlockSideEffects processes the side effects of a domain block: // // 1. Strip most info away from the instance entry for the domain. @@ -323,13 +120,6 @@ func (p *Processor) domainBlockSideEffects( ctx context.Context, block *gtsmodel.DomainBlock, ) gtserror.MultiError { - l := log. - WithContext(ctx). - WithFields(kv.Fields{ - {"domain", block.Domain}, - }...) - l.Debug("processing domain block side effects") - var errs gtserror.MultiError // If we have an instance entry for this domain, @@ -347,7 +137,6 @@ func (p *Processor) domainBlockSideEffects( errs.Appendf("db error updating instance: %w", err) return errs } - l.Debug("instance entry updated") } // For each account that belongs to this domain, @@ -372,6 +161,68 @@ func (p *Processor) domainBlockSideEffects( return errs } +func (p *Processor) deleteDomainBlock( + ctx context.Context, + adminAcct *gtsmodel.Account, + domainBlockID string, +) (*apimodel.DomainPermission, string, gtserror.WithCode) { + domainBlock, err := p.state.DB.GetDomainBlockByID(ctx, domainBlockID) + if err != nil { + if !errors.Is(err, db.ErrNoEntries) { + // Real error. + err = gtserror.Newf("db error getting domain block: %w", err) + return nil, "", gtserror.NewErrorInternalError(err) + } + + // There are just no entries for this ID. + err = fmt.Errorf("no domain block entry exists with ID %s", domainBlockID) + return nil, "", gtserror.NewErrorNotFound(err, err.Error()) + } + + // Prepare the domain block to return, *before* the deletion goes through. + apiDomainBlock, errWithCode := p.apiDomainPerm(ctx, domainBlock, false) + if errWithCode != nil { + return nil, "", errWithCode + } + + // Delete the original domain block. + if err := p.state.DB.DeleteDomainBlock(ctx, domainBlock.Domain); err != nil { + err = gtserror.Newf("db error deleting domain block: %w", err) + return nil, "", gtserror.NewErrorInternalError(err) + } + + actionID := id.NewULID() + + // Process domain unblock side + // effects asynchronously. + if errWithCode := p.actions.Run( + ctx, + >smodel.AdminAction{ + ID: actionID, + TargetCategory: gtsmodel.AdminActionCategoryDomain, + TargetID: domainBlock.Domain, + Type: gtsmodel.AdminActionUnsuspend, + AccountID: adminAcct.ID, + }, + func(ctx context.Context) gtserror.MultiError { + // Log start + finish. + l := log.WithFields(kv.Fields{ + {"domain", domainBlock.Domain}, + {"actionID", actionID}, + }...).WithContext(ctx) + + l.Info("processing domain unblock side effects") + defer func() { l.Info("finished processing domain unblock side effects") }() + + return p.domainUnblockSideEffects(ctx, domainBlock) + }, + ); errWithCode != nil { + return nil, actionID, errWithCode + } + + return apiDomainBlock, actionID, nil +} + // domainUnblockSideEffects processes the side effects of undoing a // domain block: // @@ -385,13 +236,6 @@ func (p *Processor) domainUnblockSideEffects( ctx context.Context, block *gtsmodel.DomainBlock, ) gtserror.MultiError { - l := log. - WithContext(ctx). - WithFields(kv.Fields{ - {"domain", block.Domain}, - }...) - l.Debug("processing domain unblock side effects") - var errs gtserror.MultiError // Update instance entry for this domain, if we have it. @@ -414,7 +258,6 @@ func (p *Processor) domainUnblockSideEffects( errs.Appendf("db error updating instance: %w", err) return errs } - l.Debug("instance entry updated") } // Unsuspend all accounts whose suspension origin was this domain block. diff --git a/internal/processing/admin/domainblock_test.go b/internal/processing/admin/domainblock_test.go deleted file mode 100644 index 9525ce7c3..000000000 --- a/internal/processing/admin/domainblock_test.go +++ /dev/null @@ -1,76 +0,0 @@ -// GoToSocial -// Copyright (C) GoToSocial Authors admin@gotosocial.org -// SPDX-License-Identifier: AGPL-3.0-or-later -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <http://www.gnu.org/licenses/>. - -package admin_test - -import ( - "context" - "testing" - - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type DomainBlockTestSuite struct { - AdminStandardTestSuite -} - -func (suite *DomainBlockTestSuite) TestCreateDomainBlock() { - var ( - ctx = context.Background() - adminAcct = suite.testAccounts["admin_account"] - domain = "fossbros-anonymous.io" - obfuscate = false - publicComment = "" - privateComment = "" - subscriptionID = "" - ) - - apiBlock, actionID, errWithCode := suite.adminProcessor.DomainBlockCreate( - ctx, - adminAcct, - domain, - obfuscate, - publicComment, - privateComment, - subscriptionID, - ) - suite.NoError(errWithCode) - suite.NotNil(apiBlock) - suite.NotEmpty(actionID) - - // Wait for action to finish. - if !testrig.WaitFor(func() bool { - return suite.adminProcessor.Actions().TotalRunning() == 0 - }) { - suite.FailNow("timed out waiting for admin action(s) to finish") - } - - // Ensure action marked as - // completed in the database. - adminAction, err := suite.db.GetAdminAction(ctx, actionID) - if err != nil { - suite.FailNow(err.Error()) - } - - suite.NotZero(adminAction.CompletedAt) - suite.Empty(adminAction.Errors) -} - -func TestDomainBlockTestSuite(t *testing.T) { - suite.Run(t, new(DomainBlockTestSuite)) -} diff --git a/internal/processing/admin/domainpermission.go b/internal/processing/admin/domainpermission.go new file mode 100644 index 000000000..c759c0f11 --- /dev/null +++ b/internal/processing/admin/domainpermission.go @@ -0,0 +1,335 @@ +// 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" + "encoding/json" + "errors" + "fmt" + "mime/multipart" + "net/http" + + 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" +) + +// apiDomainPerm is a cheeky shortcut for returning +// the API version of the given domain permission +// (*gtsmodel.DomainBlock or *gtsmodel.DomainAllow), +// or an appropriate error if something goes wrong. +func (p *Processor) apiDomainPerm( + ctx context.Context, + domainPermission gtsmodel.DomainPermission, + export bool, +) (*apimodel.DomainPermission, gtserror.WithCode) { + apiDomainPerm, err := p.tc.DomainPermToAPIDomainPerm(ctx, domainPermission, export) + if err != nil { + err := gtserror.NewfAt(3, "error converting domain permission to api model: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return apiDomainPerm, nil +} + +// DomainPermissionCreate creates an instance-level permission +// targeting the given domain, and then processes any side +// effects of the permission creation. +// +// If the same permission type already exists for the domain, +// side effects will be retried. +// +// Return values for this function are the new or existing +// domain permission, the ID of the admin action resulting +// from this call, and/or an error if something goes wrong. +func (p *Processor) DomainPermissionCreate( + ctx context.Context, + permissionType gtsmodel.DomainPermissionType, + adminAcct *gtsmodel.Account, + domain string, + obfuscate bool, + publicComment string, + privateComment string, + subscriptionID string, +) (*apimodel.DomainPermission, string, gtserror.WithCode) { + switch permissionType { + + // Explicitly block a domain. + case gtsmodel.DomainPermissionBlock: + return p.createDomainBlock( + ctx, + adminAcct, + domain, + obfuscate, + publicComment, + privateComment, + subscriptionID, + ) + + // Explicitly allow a domain. + case gtsmodel.DomainPermissionAllow: + return p.createDomainAllow( + ctx, + adminAcct, + domain, + obfuscate, + publicComment, + privateComment, + subscriptionID, + ) + + // Weeping, roaring, red-faced. + default: + err := gtserror.Newf("unrecognized permission type %d", permissionType) + return nil, "", gtserror.NewErrorInternalError(err) + } +} + +// DomainPermissionDelete removes one domain block with the given ID, +// and processes side effects of removing the block asynchronously. +// +// Return values for this function are the deleted domain block, the ID of the admin +// action resulting from this call, and/or an error if something goes wrong. +func (p *Processor) DomainPermissionDelete( + ctx context.Context, + permissionType gtsmodel.DomainPermissionType, + adminAcct *gtsmodel.Account, + domainBlockID string, +) (*apimodel.DomainPermission, string, gtserror.WithCode) { + switch permissionType { + + // Delete explicit domain block. + case gtsmodel.DomainPermissionBlock: + return p.deleteDomainBlock( + ctx, + adminAcct, + domainBlockID, + ) + + // Delete explicit domain allow. + case gtsmodel.DomainPermissionAllow: + return p.deleteDomainAllow( + ctx, + adminAcct, + domainBlockID, + ) + + // You do the hokey-cokey and you turn + // around, that's what it's all about. + default: + err := gtserror.Newf("unrecognized permission type %d", permissionType) + return nil, "", gtserror.NewErrorInternalError(err) + } +} + +// DomainPermissionsImport handles the import of multiple +// domain permissions, by calling the DomainPermissionCreate +// function for each domain in the provided file. Will return +// a slice of processed domain permissions. +// +// In the case of total failure, a gtserror.WithCode will be +// returned so that the caller can respond appropriately. In +// the case of partial or total success, a MultiStatus model +// will be returned, which contains information about success +// + failure count, so that the caller can retry any failures +// as they wish. +func (p *Processor) DomainPermissionsImport( + ctx context.Context, + permissionType gtsmodel.DomainPermissionType, + account *gtsmodel.Account, + domainsF *multipart.FileHeader, +) (*apimodel.MultiStatus, gtserror.WithCode) { + // Ensure known permission type. + if permissionType != gtsmodel.DomainPermissionBlock && + permissionType != gtsmodel.DomainPermissionAllow { + err := gtserror.Newf("unrecognized permission type %d", permissionType) + return nil, gtserror.NewErrorInternalError(err) + } + + // Open the provided file. + file, err := domainsF.Open() + if err != nil { + err = gtserror.Newf("error opening attachment: %w", err) + return nil, gtserror.NewErrorBadRequest(err, err.Error()) + } + defer file.Close() + + // Parse file as slice of domain blocks. + domainPerms := make([]*apimodel.DomainPermission, 0) + if err := json.NewDecoder(file).Decode(&domainPerms); err != nil { + err = gtserror.Newf("error parsing attachment as domain permissions: %w", err) + return nil, gtserror.NewErrorBadRequest(err, err.Error()) + } + + count := len(domainPerms) + if count == 0 { + err = gtserror.New("error importing domain permissions: 0 entries provided") + return nil, gtserror.NewErrorBadRequest(err, err.Error()) + } + + // Try to process each domain permission, differentiating + // between successes and errors so that the caller can + // try failed imports again if desired. + multiStatusEntries := make([]apimodel.MultiStatusEntry, 0, count) + + for _, domainPerm := range domainPerms { + var ( + domain = domainPerm.Domain.Domain + obfuscate = domainPerm.Obfuscate + publicComment = domainPerm.PublicComment + privateComment = domainPerm.PrivateComment + subscriptionID = "" // No sub ID for imports. + errWithCode gtserror.WithCode + ) + + domainPerm, _, errWithCode = p.DomainPermissionCreate( + ctx, + permissionType, + account, + domain, + obfuscate, + publicComment, + privateComment, + subscriptionID, + ) + + var entry *apimodel.MultiStatusEntry + + if errWithCode != nil { + entry = &apimodel.MultiStatusEntry{ + // Use the failed domain entry as the resource value. + Resource: domain, + Message: errWithCode.Safe(), + Status: errWithCode.Code(), + } + } else { + entry = &apimodel.MultiStatusEntry{ + // Use successfully created API model domain block as the resource value. + Resource: domainPerm, + Message: http.StatusText(http.StatusOK), + Status: http.StatusOK, + } + } + + multiStatusEntries = append(multiStatusEntries, *entry) + } + + return apimodel.NewMultiStatus(multiStatusEntries), nil +} + +// DomainPermissionsGet returns all existing domain +// permissions of the requested type. If export is +// true, the format will be suitable for writing out +// to an export. +func (p *Processor) DomainPermissionsGet( + ctx context.Context, + permissionType gtsmodel.DomainPermissionType, + account *gtsmodel.Account, + export bool, +) ([]*apimodel.DomainPermission, gtserror.WithCode) { + var ( + domainPerms []gtsmodel.DomainPermission + err error + ) + + switch permissionType { + case gtsmodel.DomainPermissionBlock: + var blocks []*gtsmodel.DomainBlock + + blocks, err = p.state.DB.GetDomainBlocks(ctx) + if err != nil { + break + } + + for _, block := range blocks { + domainPerms = append(domainPerms, block) + } + + case gtsmodel.DomainPermissionAllow: + var allows []*gtsmodel.DomainAllow + + allows, err = p.state.DB.GetDomainAllows(ctx) + if err != nil { + break + } + + for _, allow := range allows { + domainPerms = append(domainPerms, allow) + } + + default: + err = errors.New("unrecognized permission type") + } + + if err != nil { + err := gtserror.Newf("error getting %ss: %w", permissionType.String(), err) + return nil, gtserror.NewErrorInternalError(err) + } + + apiDomainPerms := make([]*apimodel.DomainPermission, len(domainPerms)) + for i, domainPerm := range domainPerms { + apiDomainBlock, errWithCode := p.apiDomainPerm(ctx, domainPerm, export) + if errWithCode != nil { + return nil, errWithCode + } + + apiDomainPerms[i] = apiDomainBlock + } + + return apiDomainPerms, nil +} + +// DomainPermissionGet returns one domain +// permission with the given id and type. +// +// If export is true, the format will be +// suitable for writing out to an export. +func (p *Processor) DomainPermissionGet( + ctx context.Context, + permissionType gtsmodel.DomainPermissionType, + id string, + export bool, +) (*apimodel.DomainPermission, gtserror.WithCode) { + var ( + domainPerm gtsmodel.DomainPermission + err error + ) + + switch permissionType { + case gtsmodel.DomainPermissionBlock: + domainPerm, err = p.state.DB.GetDomainBlockByID(ctx, id) + case gtsmodel.DomainPermissionAllow: + domainPerm, err = p.state.DB.GetDomainAllowByID(ctx, id) + default: + err = gtserror.New("unrecognized permission type") + } + + if err != nil { + if errors.Is(err, db.ErrNoEntries) { + err = fmt.Errorf("no domain %s exists with id %s", permissionType.String(), id) + return nil, gtserror.NewErrorNotFound(err, err.Error()) + } + + err = gtserror.Newf("error getting domain %s with id %s: %w", permissionType.String(), id, err) + return nil, gtserror.NewErrorInternalError(err) + } + + return p.apiDomainPerm(ctx, domainPerm, export) +} diff --git a/internal/processing/admin/domainpermission_test.go b/internal/processing/admin/domainpermission_test.go new file mode 100644 index 000000000..b6de226c1 --- /dev/null +++ b/internal/processing/admin/domainpermission_test.go @@ -0,0 +1,280 @@ +// 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" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type DomainBlockTestSuite struct { + AdminStandardTestSuite +} + +type domainPermAction struct { + // 'create' or 'delete' + // the domain permission. + createOrDelete string + + // Type of permission + // to create or delete. + permissionType gtsmodel.DomainPermissionType + + // Domain to target + // with the permission. + domain string + + // Expected result of this + // permission action on each + // account on the target domain. + // Eg., suite.Zero(account.SuspendedAt) + expected func(*gtsmodel.Account) bool +} + +type domainPermTest struct { + // Federation mode under which to + // run this test. This is important + // because it may effect which side + // effects are taken, if any. + instanceFederationMode string + + // Series of actions to run as part + // of this test. After each action, + // expected will be called. This + // allows testers to run multiple + // actions in a row and check that + // the results after each action are + // what they expected, in light of + // previous actions. + actions []domainPermAction +} + +// run a domainPermTest by running each of +// its actions in turn and checking results. +func (suite *DomainBlockTestSuite) runDomainPermTest(t domainPermTest) { + config.SetInstanceFederationMode(t.instanceFederationMode) + + for _, action := range t.actions { + // Run the desired action. + var actionID string + switch action.createOrDelete { + case "create": + _, actionID = suite.createDomainPerm(action.permissionType, action.domain) + case "delete": + _, actionID = suite.deleteDomainPerm(action.permissionType, action.domain) + default: + panic("createOrDelete was not 'create' or 'delete'") + } + + // Let the action finish. + suite.awaitAction(actionID) + + // Check expected results + // against each account. + accounts, err := suite.db.GetInstanceAccounts( + context.Background(), + action.domain, + "", 0, + ) + if err != nil { + suite.FailNow("", "error getting instance accounts for %s: %v", action.domain, err) + } + + for _, account := range accounts { + if !action.expected(account) { + suite.T().FailNow() + } + } + } +} + +// create given permissionType with default values. +func (suite *DomainBlockTestSuite) createDomainPerm( + permissionType gtsmodel.DomainPermissionType, + domain string, +) (*apimodel.DomainPermission, string) { + ctx := context.Background() + + apiPerm, actionID, errWithCode := suite.adminProcessor.DomainPermissionCreate( + ctx, + permissionType, + suite.testAccounts["admin_account"], + domain, + false, + "", + "", + "", + ) + suite.NoError(errWithCode) + suite.NotNil(apiPerm) + suite.NotEmpty(actionID) + + return apiPerm, actionID +} + +// delete given permission type. +func (suite *DomainBlockTestSuite) deleteDomainPerm( + permissionType gtsmodel.DomainPermissionType, + domain string, +) (*apimodel.DomainPermission, string) { + var ( + ctx = context.Background() + domainPermission gtsmodel.DomainPermission + ) + + // To delete the permission, + // first get it from the db. + switch permissionType { + case gtsmodel.DomainPermissionBlock: + domainPermission, _ = suite.db.GetDomainBlock(ctx, domain) + case gtsmodel.DomainPermissionAllow: + domainPermission, _ = suite.db.GetDomainAllow(ctx, domain) + default: + panic("unrecognized permission type") + } + + if domainPermission == nil { + suite.FailNow("domain permission was nil") + } + + // Now use the ID to delete it. + apiPerm, actionID, errWithCode := suite.adminProcessor.DomainPermissionDelete( + ctx, + permissionType, + suite.testAccounts["admin_account"], + domainPermission.GetID(), + ) + suite.NoError(errWithCode) + suite.NotNil(apiPerm) + suite.NotEmpty(actionID) + + return apiPerm, actionID +} + +// waits for given actionID to be completed. +func (suite *DomainBlockTestSuite) awaitAction(actionID string) { + ctx := context.Background() + + if !testrig.WaitFor(func() bool { + return suite.adminProcessor.Actions().TotalRunning() == 0 + }) { + suite.FailNow("timed out waiting for admin action(s) to finish") + } + + // Ensure action marked as + // completed in the database. + adminAction, err := suite.db.GetAdminAction(ctx, actionID) + if err != nil { + suite.FailNow(err.Error()) + } + + suite.NotZero(adminAction.CompletedAt) + suite.Empty(adminAction.Errors) +} + +func (suite *DomainBlockTestSuite) TestBlockAndUnblockDomain() { + const domain = "fossbros-anonymous.io" + + suite.runDomainPermTest(domainPermTest{ + instanceFederationMode: config.InstanceFederationModeBlocklist, + actions: []domainPermAction{ + { + createOrDelete: "create", + permissionType: gtsmodel.DomainPermissionBlock, + domain: domain, + expected: func(account *gtsmodel.Account) bool { + // Domain was blocked, so each + // account should now be suspended. + return suite.NotZero(account.SuspendedAt) + }, + }, + { + createOrDelete: "delete", + permissionType: gtsmodel.DomainPermissionBlock, + domain: domain, + expected: func(account *gtsmodel.Account) bool { + // Domain was unblocked, so each + // account should now be unsuspended. + return suite.Zero(account.SuspendedAt) + }, + }, + }, + }) +} + +func (suite *DomainBlockTestSuite) TestBlockAndAllowDomain() { + const domain = "fossbros-anonymous.io" + + suite.runDomainPermTest(domainPermTest{ + instanceFederationMode: config.InstanceFederationModeBlocklist, + actions: []domainPermAction{ + { + createOrDelete: "create", + permissionType: gtsmodel.DomainPermissionBlock, + domain: domain, + expected: func(account *gtsmodel.Account) bool { + // Domain was blocked, so each + // account should now be suspended. + return suite.NotZero(account.SuspendedAt) + }, + }, + { + createOrDelete: "create", + permissionType: gtsmodel.DomainPermissionAllow, + domain: domain, + expected: func(account *gtsmodel.Account) bool { + // Domain was explicitly allowed, so each + // account should now be unsuspended, since + // the allow supercedes the block. + return suite.Zero(account.SuspendedAt) + }, + }, + { + createOrDelete: "delete", + permissionType: gtsmodel.DomainPermissionAllow, + domain: domain, + expected: func(account *gtsmodel.Account) bool { + // Deleting the allow now, while there's + // still a block in place, should cause + // the block to take effect again. + return suite.NotZero(account.SuspendedAt) + }, + }, + { + createOrDelete: "delete", + permissionType: gtsmodel.DomainPermissionBlock, + domain: domain, + expected: func(account *gtsmodel.Account) bool { + // Deleting the block now should + // unsuspend the accounts again. + return suite.Zero(account.SuspendedAt) + }, + }, + }, + }) +} + +func TestDomainBlockTestSuite(t *testing.T) { + suite.Run(t, new(DomainBlockTestSuite)) +} diff --git a/internal/processing/admin/util.go b/internal/processing/admin/util.go index 403602901..c82ff2dc1 100644 --- a/internal/processing/admin/util.go +++ b/internal/processing/admin/util.go @@ -22,28 +22,11 @@ import ( "errors" "time" - 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" ) -// apiDomainBlock is a cheeky shortcut for returning -// the API version of the given domainBlock, or an -// appropriate error if something goes wrong. -func (p *Processor) apiDomainBlock( - ctx context.Context, - domainBlock *gtsmodel.DomainBlock, -) (*apimodel.DomainBlock, gtserror.WithCode) { - apiDomainBlock, err := p.tc.DomainBlockToAPIDomainBlock(ctx, domainBlock, false) - if err != nil { - err = gtserror.Newf("error converting domain block for %s to api model : %w", domainBlock.Domain, err) - return nil, gtserror.NewErrorInternalError(err) - } - - return apiDomainBlock, nil -} - // stubbifyInstance renders the given instance as a stub, // removing most information from it and marking it as // suspended. diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go index 774b68157..af77734cc 100644 --- a/internal/typeutils/converter.go +++ b/internal/typeutils/converter.go @@ -91,8 +91,8 @@ type TypeConverter interface { RelationshipToAPIRelationship(ctx context.Context, r *gtsmodel.Relationship) (*apimodel.Relationship, error) // NotificationToAPINotification converts a gts notification into a api notification NotificationToAPINotification(ctx context.Context, n *gtsmodel.Notification) (*apimodel.Notification, error) - // DomainBlockToAPIDomainBlock converts a gts model domin block into a api domain block, for serving at /api/v1/admin/domain_blocks - DomainBlockToAPIDomainBlock(ctx context.Context, b *gtsmodel.DomainBlock, export bool) (*apimodel.DomainBlock, error) + // DomainPermToAPIDomainPerm converts a gts model domin block or allow into an api domain permission. + DomainPermToAPIDomainPerm(ctx context.Context, d gtsmodel.DomainPermission, export bool) (*apimodel.DomainPermission, error) // ReportToAPIReport converts a gts model report into an api model report, for serving at /api/v1/reports ReportToAPIReport(ctx context.Context, r *gtsmodel.Report) (*apimodel.Report, error) // ReportToAdminAPIReport converts a gts model report into an admin view report, for serving at /api/v1/admin/reports diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index 050997bda..11838e2bd 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -1041,32 +1041,39 @@ func (c *converter) NotificationToAPINotification(ctx context.Context, n *gtsmod }, nil } -func (c *converter) DomainBlockToAPIDomainBlock(ctx context.Context, b *gtsmodel.DomainBlock, export bool) (*apimodel.DomainBlock, error) { +func (c *converter) DomainPermToAPIDomainPerm( + ctx context.Context, + d gtsmodel.DomainPermission, + export bool, +) (*apimodel.DomainPermission, error) { // Domain may be in Punycode, // de-punify it just in case. - d, err := util.DePunify(b.Domain) + domain, err := util.DePunify(d.GetDomain()) if err != nil { - return nil, fmt.Errorf("DomainBlockToAPIDomainBlock: error de-punifying domain %s: %w", b.Domain, err) + return nil, gtserror.Newf("error de-punifying domain %s: %w", d.GetDomain(), err) } - domainBlock := &apimodel.DomainBlock{ + domainPerm := &apimodel.DomainPermission{ Domain: apimodel.Domain{ - Domain: d, - PublicComment: b.PublicComment, + Domain: domain, + PublicComment: d.GetPublicComment(), }, } - // if we're exporting a domain block, return it with minimal information attached - if !export { - domainBlock.ID = b.ID - domainBlock.Obfuscate = *b.Obfuscate - domainBlock.PrivateComment = b.PrivateComment - domainBlock.SubscriptionID = b.SubscriptionID - domainBlock.CreatedBy = b.CreatedByAccountID - domainBlock.CreatedAt = util.FormatISO8601(b.CreatedAt) + // If we're exporting, provide + // only bare minimum detail. + if export { + return domainPerm, nil } - return domainBlock, nil + domainPerm.ID = d.GetID() + domainPerm.Obfuscate = *d.GetObfuscate() + domainPerm.PrivateComment = d.GetPrivateComment() + domainPerm.SubscriptionID = d.GetSubscriptionID() + domainPerm.CreatedBy = d.GetCreatedByAccountID() + domainPerm.CreatedAt = util.FormatISO8601(d.GetCreatedAt()) + + return domainPerm, nil } func (c *converter) ReportToAPIReport(ctx context.Context, r *gtsmodel.Report) (*apimodel.Report, error) { |