From 183eaa5b298235acb8f25ba8f18b98e31471d965 Mon Sep 17 00:00:00 2001
From: tobi <31960611+tsmethurst@users.noreply.github.com>
Date: Thu, 21 Sep 2023 12:12:04 +0200
Subject: [feature] Implement explicit domain allows + allowlist federation
 mode (#2200)
* love like winter! wohoah, wohoah
* domain allow side effects
* tests! logging! unallow!
* document federation modes
* linty linterson
* test
* further adventures in documentation
* finish up domain block documentation (i think)
* change wording a wee little bit
* docs, example
* consolidate shared domainPermission code
* call mode once
* fetch federation mode within domain blocked func
* read domain perm import in streaming manner
* don't use pointer to slice for domain perms
* don't bother copying blocks + allows before deleting
* admonish!
* change wording just a scooch
* update docs
---
 internal/api/client/admin/admin.go             |   8 +
 internal/api/client/admin/domainallowcreate.go | 128 +++++++++++
 internal/api/client/admin/domainallowdelete.go |  72 ++++++
 internal/api/client/admin/domainallowget.go    |  67 ++++++
 internal/api/client/admin/domainallowsget.go   |  73 ++++++
 internal/api/client/admin/domainblockcreate.go | 118 +---------
 internal/api/client/admin/domainblockdelete.go |  42 +---
 internal/api/client/admin/domainblockget.go    |  46 +---
 internal/api/client/admin/domainblocksget.go   |  40 +---
 internal/api/client/admin/domainpermission.go  | 295 +++++++++++++++++++++++++
 10 files changed, 658 insertions(+), 231 deletions(-)
 create mode 100644 internal/api/client/admin/domainallowcreate.go
 create mode 100644 internal/api/client/admin/domainallowdelete.go
 create mode 100644 internal/api/client/admin/domainallowget.go
 create mode 100644 internal/api/client/admin/domainallowsget.go
 create mode 100644 internal/api/client/admin/domainpermission.go
(limited to 'internal/api/client')
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 .
+
+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 .
+
+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 .
+
+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 .
+
+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 .
+
+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)
+}
-- 
cgit v1.2.3