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