diff options
author | 2024-11-21 14:09:58 +0100 | |
---|---|---|
committer | 2024-11-21 13:09:58 +0000 | |
commit | 301543616b5376585a7caff097499421acdf1806 (patch) | |
tree | 4cac6aea2c33687b1339fc3bc18e6eb64def6f9a /internal | |
parent | [feature] Allow emoji shortcode to be 1-character length (#3556) (diff) | |
download | gotosocial-301543616b5376585a7caff097499421acdf1806.tar.xz |
[feature] Add domain permission drafts and excludes (#3547)
* [feature] Add domain permission drafts and excludes
* fix typescript complaining
* lint
* make filenames more consistent
* test own domain excluded
Diffstat (limited to 'internal')
36 files changed, 3241 insertions, 60 deletions
diff --git a/internal/api/client/admin/admin.go b/internal/api/client/admin/admin.go index 2c55de2f0..a33a6448a 100644 --- a/internal/api/client/admin/admin.go +++ b/internal/api/client/admin/admin.go @@ -28,37 +28,43 @@ import ( ) const ( - BasePath = "/v1/admin" - EmojiPath = BasePath + "/custom_emojis" - EmojiPathWithID = EmojiPath + "/:" + apiutil.IDKey - EmojiCategoriesPath = EmojiPath + "/categories" - DomainBlocksPath = BasePath + "/domain_blocks" - DomainBlocksPathWithID = DomainBlocksPath + "/:" + apiutil.IDKey - DomainAllowsPath = BasePath + "/domain_allows" - DomainAllowsPathWithID = DomainAllowsPath + "/:" + apiutil.IDKey - DomainKeysExpirePath = BasePath + "/domain_keys_expire" - HeaderAllowsPath = BasePath + "/header_allows" - HeaderAllowsPathWithID = HeaderAllowsPath + "/:" + apiutil.IDKey - HeaderBlocksPath = BasePath + "/header_blocks" - HeaderBlocksPathWithID = HeaderBlocksPath + "/:" + apiutil.IDKey - AccountsV1Path = BasePath + "/accounts" - AccountsV2Path = "/v2/admin/accounts" - AccountsPathWithID = AccountsV1Path + "/:" + apiutil.IDKey - AccountsActionPath = AccountsPathWithID + "/action" - AccountsApprovePath = AccountsPathWithID + "/approve" - AccountsRejectPath = AccountsPathWithID + "/reject" - MediaCleanupPath = BasePath + "/media_cleanup" - MediaRefetchPath = BasePath + "/media_refetch" - ReportsPath = BasePath + "/reports" - ReportsPathWithID = ReportsPath + "/:" + apiutil.IDKey - ReportsResolvePath = ReportsPathWithID + "/resolve" - EmailPath = BasePath + "/email" - EmailTestPath = EmailPath + "/test" - InstanceRulesPath = BasePath + "/instance/rules" - InstanceRulesPathWithID = InstanceRulesPath + "/:" + apiutil.IDKey - DebugPath = BasePath + "/debug" - DebugAPUrlPath = DebugPath + "/apurl" - DebugClearCachesPath = DebugPath + "/caches/clear" + BasePath = "/v1/admin" + EmojiPath = BasePath + "/custom_emojis" + EmojiPathWithID = EmojiPath + "/:" + apiutil.IDKey + EmojiCategoriesPath = EmojiPath + "/categories" + DomainBlocksPath = BasePath + "/domain_blocks" + DomainBlocksPathWithID = DomainBlocksPath + "/:" + apiutil.IDKey + DomainAllowsPath = BasePath + "/domain_allows" + DomainAllowsPathWithID = DomainAllowsPath + "/:" + apiutil.IDKey + DomainPermissionDraftsPath = BasePath + "/domain_permission_drafts" + DomainPermissionDraftsPathWithID = DomainPermissionDraftsPath + "/:" + apiutil.IDKey + DomainPermissionDraftAcceptPath = DomainPermissionDraftsPathWithID + "/accept" + DomainPermissionDraftRemovePath = DomainPermissionDraftsPathWithID + "/remove" + DomainPermissionExcludesPath = BasePath + "/domain_permission_excludes" + DomainPermissionExcludesPathWithID = DomainPermissionExcludesPath + "/:" + apiutil.IDKey + DomainKeysExpirePath = BasePath + "/domain_keys_expire" + HeaderAllowsPath = BasePath + "/header_allows" + HeaderAllowsPathWithID = HeaderAllowsPath + "/:" + apiutil.IDKey + HeaderBlocksPath = BasePath + "/header_blocks" + HeaderBlocksPathWithID = HeaderBlocksPath + "/:" + apiutil.IDKey + AccountsV1Path = BasePath + "/accounts" + AccountsV2Path = "/v2/admin/accounts" + AccountsPathWithID = AccountsV1Path + "/:" + apiutil.IDKey + AccountsActionPath = AccountsPathWithID + "/action" + AccountsApprovePath = AccountsPathWithID + "/approve" + AccountsRejectPath = AccountsPathWithID + "/reject" + MediaCleanupPath = BasePath + "/media_cleanup" + MediaRefetchPath = BasePath + "/media_refetch" + ReportsPath = BasePath + "/reports" + ReportsPathWithID = ReportsPath + "/:" + apiutil.IDKey + ReportsResolvePath = ReportsPathWithID + "/resolve" + EmailPath = BasePath + "/email" + EmailTestPath = EmailPath + "/test" + InstanceRulesPath = BasePath + "/instance/rules" + InstanceRulesPathWithID = InstanceRulesPath + "/:" + apiutil.IDKey + DebugPath = BasePath + "/debug" + DebugAPUrlPath = DebugPath + "/apurl" + DebugClearCachesPath = DebugPath + "/caches/clear" FilterQueryKey = "filter" MaxShortcodeDomainKey = "max_shortcode_domain" @@ -99,6 +105,19 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H attachHandler(http.MethodGet, DomainAllowsPathWithID, m.DomainAllowGETHandler) attachHandler(http.MethodDelete, DomainAllowsPathWithID, m.DomainAllowDELETEHandler) + // domain permission draft stuff + attachHandler(http.MethodPost, DomainPermissionDraftsPath, m.DomainPermissionDraftsPOSTHandler) + attachHandler(http.MethodGet, DomainPermissionDraftsPath, m.DomainPermissionDraftsGETHandler) + attachHandler(http.MethodGet, DomainPermissionDraftsPathWithID, m.DomainPermissionDraftGETHandler) + attachHandler(http.MethodPost, DomainPermissionDraftAcceptPath, m.DomainPermissionDraftAcceptPOSTHandler) + attachHandler(http.MethodPost, DomainPermissionDraftRemovePath, m.DomainPermissionDraftRemovePOSTHandler) + + // domain permission excludes stuff + attachHandler(http.MethodPost, DomainPermissionExcludesPath, m.DomainPermissionExcludesPOSTHandler) + attachHandler(http.MethodGet, DomainPermissionExcludesPath, m.DomainPermissionExcludesGETHandler) + attachHandler(http.MethodGet, DomainPermissionExcludesPathWithID, m.DomainPermissionExcludeGETHandler) + attachHandler(http.MethodDelete, DomainPermissionExcludesPathWithID, m.DomainPermissionExcludeDELETEHandler) + // header filtering administration routes attachHandler(http.MethodGet, HeaderAllowsPathWithID, m.HeaderFilterAllowGET) attachHandler(http.MethodGet, HeaderBlocksPathWithID, m.HeaderFilterBlockGET) diff --git a/internal/api/client/admin/domainpermissiondraftaccept.go b/internal/api/client/admin/domainpermissiondraftaccept.go new file mode 100644 index 000000000..5e484cbf3 --- /dev/null +++ b/internal/api/client/admin/domainpermissiondraftaccept.go @@ -0,0 +1,134 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package admin + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// DomainPermissionDraftAcceptPOSTHandler swagger:operation POST /api/v1/admin/domain_permission_drafts/{id}/accept domainPermissionDraftAccept +// +// Accept a domain permission draft, turning it into an enforced domain permission. +// +// --- +// tags: +// - admin +// +// consumes: +// - multipart/form-data +// - application/json +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// required: true +// in: path +// description: ID of the domain permission draft. +// type: string +// - +// name: overwrite +// in: formData +// description: >- +// If a domain permission already exists with the same +// domain and permission type as the draft, overwrite +// the existing permission with fields from the draft. +// type: boolean +// default: false +// +// security: +// - OAuth2 Bearer: +// - admin +// +// responses: +// '200': +// description: The newly created domain permission. +// schema: +// "$ref": "#/definitions/domainPermission" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '406': +// description: not acceptable +// '409': +// description: conflict +// '500': +// description: internal server error +func (m *Module) DomainPermissionDraftAcceptPOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if !*authed.User.Admin { + err := fmt.Errorf("user %s not an admin", authed.User.ID) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if authed.Account.IsMoving() { + apiutil.ForbiddenAfterMove(c) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + type AcceptForm struct { + Overwrite bool `json:"overwrite" form:"overwrite"` + } + + form := new(AcceptForm) + if err := c.ShouldBind(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + return + } + + domainPerm, _, errWithCode := m.processor.Admin().DomainPermissionDraftAccept( + c.Request.Context(), + authed.Account, + id, + form.Overwrite, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, domainPerm) +} diff --git a/internal/api/client/admin/domainpermissiondraftcreate.go b/internal/api/client/admin/domainpermissiondraftcreate.go new file mode 100644 index 000000000..d20842ebf --- /dev/null +++ b/internal/api/client/admin/domainpermissiondraftcreate.go @@ -0,0 +1,176 @@ +// 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 ( + "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/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// DomainPermissionDraftsPOSTHandler swagger:operation POST /api/v1/admin/domain_permission_drafts domainPermissionDraftCreate +// +// Create a domain permission draft with the given parameters. +// +// --- +// tags: +// - admin +// +// consumes: +// - multipart/form-data +// - application/json +// +// produces: +// - application/json +// +// parameters: +// - +// name: domain +// in: formData +// description: Domain to create the permission draft for. +// type: string +// - +// name: permission_type +// in: formData +// description: Create a draft "allow" or a draft "block". +// 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`. +// type: boolean +// - +// name: public_comment +// in: formData +// description: >- +// Public comment about this domain permission. +// This will be displayed alongside the domain permission if you choose to share permissions. +// type: string +// - +// name: private_comment +// in: formData +// description: >- +// Private comment about this domain permission. Will only be shown to other admins, so this +// is a useful way of internally keeping track of why a certain domain ended up permissioned. +// type: string +// +// security: +// - OAuth2 Bearer: +// - admin +// +// responses: +// '200': +// description: The newly created domain permission draft. +// schema: +// "$ref": "#/definitions/domainPermission" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '406': +// description: not acceptable +// '409': +// description: conflict +// '500': +// description: internal server error +func (m *Module) DomainPermissionDraftsPOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if !*authed.User.Admin { + err := fmt.Errorf("user %s not an admin", authed.User.ID) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if authed.Account.IsMoving() { + apiutil.ForbiddenAfterMove(c) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + // 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 form.Domain == "" { + const errText = "domain must be set" + errWithCode := gtserror.NewErrorBadRequest(errors.New(errText), errText) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + var ( + permType gtsmodel.DomainPermissionType + errText string + ) + + switch pt := form.PermissionType; pt { + case "block": + permType = gtsmodel.DomainPermissionBlock + case "allow": + permType = gtsmodel.DomainPermissionAllow + case "": + errText = "permission_type not set, must be one of block or allow" + default: + errText = fmt.Sprintf("permission_type %s not recognized, must be one of block or allow", pt) + } + + if errText != "" { + errWithCode := gtserror.NewErrorBadRequest(errors.New(errText), errText) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + permDraft, errWithCode := m.processor.Admin().DomainPermissionDraftCreate( + c.Request.Context(), + authed.Account, + form.Domain, + permType, + form.Obfuscate, + form.PublicComment, + form.PrivateComment, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, permDraft) +} diff --git a/internal/api/client/admin/domainpermissiondraftget.go b/internal/api/client/admin/domainpermissiondraftget.go new file mode 100644 index 000000000..aef3b094b --- /dev/null +++ b/internal/api/client/admin/domainpermissiondraftget.go @@ -0,0 +1,104 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package admin + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// DomainPermissionDraftGETHandler swagger:operation GET /api/v1/admin/domain_permission_drafts/{id} domainPermissionDraftGet +// +// Get domain permission draft with the given ID. +// +// --- +// tags: +// - admin +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// required: true +// in: path +// description: ID of the domain permission draft. +// type: string +// +// security: +// - OAuth2 Bearer: +// - admin +// +// responses: +// '200': +// description: Domain permission draft. +// schema: +// "$ref": "#/definitions/domainPermission" +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) DomainPermissionDraftGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if !*authed.User.Admin { + err := fmt.Errorf("user %s not an admin", authed.User.ID) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if authed.Account.IsMoving() { + apiutil.ForbiddenAfterMove(c) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + permDraft, errWithCode := m.processor.Admin().DomainPermissionDraftGet(c.Request.Context(), id) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, permDraft) +} diff --git a/internal/api/client/admin/domainpermissiondraftremove.go b/internal/api/client/admin/domainpermissiondraftremove.go new file mode 100644 index 000000000..78169508c --- /dev/null +++ b/internal/api/client/admin/domainpermissiondraftremove.go @@ -0,0 +1,134 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package admin + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// DomainPermissionDraftRemovePOSTHandler swagger:operation POST /api/v1/admin/domain_permission_drafts/{id}/remove domainPermissionDraftRemove +// +// Remove a domain permission draft, optionally ignoring all future drafts targeting the given domain. +// +// --- +// tags: +// - admin +// +// consumes: +// - multipart/form-data +// - application/json +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// required: true +// in: path +// description: ID of the domain permission draft. +// type: string +// - +// name: exclude_target +// in: formData +// description: >- +// When removing the domain permission draft, also create a +// domain exclude entry for the target domain, so that drafts +// will not be created for this domain in the future. +// type: boolean +// default: false +// +// security: +// - OAuth2 Bearer: +// - admin +// +// responses: +// '200': +// description: The removed domain permission draft. +// schema: +// "$ref": "#/definitions/domainPermission" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '406': +// description: not acceptable +// '409': +// description: conflict +// '500': +// description: internal server error +func (m *Module) DomainPermissionDraftRemovePOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if !*authed.User.Admin { + err := fmt.Errorf("user %s not an admin", authed.User.ID) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if authed.Account.IsMoving() { + apiutil.ForbiddenAfterMove(c) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + type RemoveForm struct { + ExcludeTarget bool `json:"exclude_target" form:"exclude_target"` + } + + form := new(RemoveForm) + if err := c.ShouldBind(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + return + } + + domainPerm, errWithCode := m.processor.Admin().DomainPermissionDraftRemove( + c.Request.Context(), + authed.Account, + id, + form.ExcludeTarget, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, domainPerm) +} diff --git a/internal/api/client/admin/domainpermissiondraftsget.go b/internal/api/client/admin/domainpermissiondraftsget.go new file mode 100644 index 000000000..dd3315857 --- /dev/null +++ b/internal/api/client/admin/domainpermissiondraftsget.go @@ -0,0 +1,189 @@ +// 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 ( + "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/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/paging" +) + +// DomainPermissionDraftsGETHandler swagger:operation GET /api/v1/admin/domain_permission_drafts domainPermissionDraftsGet +// +// View domain permission drafts. +// +// The drafts will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer). +// +// The next and previous queries can be parsed from the returned Link header. +// +// Example: +// +// ``` +// <https://example.org/api/v1/admin/domain_permission_drafts?limit=20&max_id=01FC0SKA48HNSVR6YKZCQGS2V8>; rel="next", <https://example.org/api/v1/admin/domain_permission_drafts?limit=20&min_id=01FC0SKW5JK2Q4EVAV2B462YY0>; rel="prev" +// ```` +// +// --- +// tags: +// - admin +// +// produces: +// - application/json +// +// parameters: +// - +// name: subscription_id +// type: string +// description: Show only drafts created by the given subscription ID. +// in: query +// - +// name: domain +// type: string +// description: Return only drafts that target the given domain. +// in: query +// - +// name: permission_type +// type: string +// description: Filter on "block" or "allow" type drafts. +// in: query +// - +// name: max_id +// type: string +// description: >- +// Return only items *OLDER* than the given max ID (for paging downwards). +// The item with the specified ID will not be included in the response. +// in: query +// - +// name: since_id +// type: string +// description: >- +// Return only items *NEWER* than the given since ID. +// The item with the specified ID will not be included in the response. +// in: query +// - +// name: min_id +// type: string +// description: >- +// Return only items immediately *NEWER* than the given min ID (for paging upwards). +// The item with the specified ID will not be included in the response. +// in: query +// - +// name: limit +// type: integer +// description: Number of items to return. +// default: 20 +// minimum: 1 +// maximum: 100 +// in: query +// +// security: +// - OAuth2 Bearer: +// - admin +// +// responses: +// '200': +// description: Domain permission drafts. +// schema: +// type: array +// items: +// "$ref": "#/definitions/domainPermission" +// headers: +// Link: +// type: string +// description: Links to the next and previous queries. +// '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) DomainPermissionDraftsGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if !*authed.User.Admin { + err := fmt.Errorf("user %s not an admin", authed.User.ID) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if authed.Account.IsMoving() { + apiutil.ForbiddenAfterMove(c) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + permType := c.Query(apiutil.DomainPermissionPermTypeKey) + switch permType { + case "", "block", "allow": + // No problem. + + default: + // Invalid. + text := fmt.Sprintf( + "permission_type %s not recognized, valid values are empty string, block, or allow", + permType, + ) + errWithCode := gtserror.NewErrorBadRequest(errors.New(text), text) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + page, errWithCode := paging.ParseIDPage(c, 1, 200, 20) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + resp, errWithCode := m.processor.Admin().DomainPermissionDraftsGet( + c.Request.Context(), + c.Query(apiutil.DomainPermissionSubscriptionIDKey), + c.Query(apiutil.DomainPermissionDomainKey), + gtsmodel.NewDomainPermissionType(permType), + page, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + if resp.LinkHeader != "" { + c.Header("Link", resp.LinkHeader) + } + + apiutil.JSON(c, http.StatusOK, resp.Items) +} diff --git a/internal/api/client/admin/domainpermissionexcludecreate.go b/internal/api/client/admin/domainpermissionexcludecreate.go new file mode 100644 index 000000000..dd0b3b493 --- /dev/null +++ b/internal/api/client/admin/domainpermissionexcludecreate.go @@ -0,0 +1,138 @@ +// 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 ( + "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" +) + +// DomainPermissionExcludesPOSTHandler swagger:operation POST /api/v1/admin/domain_permission_excludes domainPermissionExcludeCreate +// +// Create a domain permission exclude with the given parameters. +// +// Excluded domains (and their subdomains) will not be automatically blocked or allowed when a list of domain permissions is imported or subscribed to. +// +// You can still manually create domain blocks or domain allows for excluded domains, and any new or existing domain blocks or domain allows for an excluded domain will still be enforced. +// +// --- +// tags: +// - admin +// +// consumes: +// - multipart/form-data +// - application/json +// +// produces: +// - application/json +// +// parameters: +// - +// name: domain +// in: formData +// description: Domain to create the permission exclude for. +// type: string +// - +// name: private_comment +// in: formData +// description: >- +// Private comment about this domain exclude. +// type: string +// +// security: +// - OAuth2 Bearer: +// - admin +// +// responses: +// '200': +// description: The newly created domain permission exclude. +// schema: +// "$ref": "#/definitions/domainPermission" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '406': +// description: not acceptable +// '409': +// description: conflict +// '500': +// description: internal server error +func (m *Module) DomainPermissionExcludesPOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if !*authed.User.Admin { + err := fmt.Errorf("user %s not an admin", authed.User.ID) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if authed.Account.IsMoving() { + apiutil.ForbiddenAfterMove(c) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + // Parse + validate form. + type ExcludeForm struct { + Domain string `form:"domain" json:"domain"` + PrivateComment string `form:"private_comment" json:"private_comment"` + } + + form := new(ExcludeForm) + if err := c.ShouldBind(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if form.Domain == "" { + const errText = "domain must be set" + errWithCode := gtserror.NewErrorBadRequest(errors.New(errText), errText) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + permExclude, errWithCode := m.processor.Admin().DomainPermissionExcludeCreate( + c.Request.Context(), + authed.Account, + form.Domain, + form.PrivateComment, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, permExclude) +} diff --git a/internal/api/client/admin/domainpermissionexcludeget.go b/internal/api/client/admin/domainpermissionexcludeget.go new file mode 100644 index 000000000..ca110abd5 --- /dev/null +++ b/internal/api/client/admin/domainpermissionexcludeget.go @@ -0,0 +1,104 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package admin + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// DomainPermissionExcludeGETHandler swagger:operation GET /api/v1/admin/domain_permission_excludes/{id} domainPermissionExcludeGet +// +// Get domain permission exclude with the given ID. +// +// --- +// tags: +// - admin +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// required: true +// in: path +// description: ID of the domain permission exclude. +// type: string +// +// security: +// - OAuth2 Bearer: +// - admin +// +// responses: +// '200': +// description: Domain permission exclude. +// schema: +// "$ref": "#/definitions/domainPermission" +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) DomainPermissionExcludeGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if !*authed.User.Admin { + err := fmt.Errorf("user %s not an admin", authed.User.ID) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if authed.Account.IsMoving() { + apiutil.ForbiddenAfterMove(c) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + permExclude, errWithCode := m.processor.Admin().DomainPermissionExcludeGet(c.Request.Context(), id) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, permExclude) +} diff --git a/internal/api/client/admin/domainpermissionexcluderemove.go b/internal/api/client/admin/domainpermissionexcluderemove.go new file mode 100644 index 000000000..a167ae5a5 --- /dev/null +++ b/internal/api/client/admin/domainpermissionexcluderemove.go @@ -0,0 +1,110 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package admin + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// DomainPermissionExcludeDELETEHandler swagger:operation DELETE /api/v1/admin/domain_permission_excludes/{id} domainPermissionExcludeDelete +// +// Remove a domain permission exclude. +// +// --- +// tags: +// - admin +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// required: true +// in: path +// description: ID of the domain permission exclude. +// type: string +// +// security: +// - OAuth2 Bearer: +// - admin +// +// responses: +// '200': +// description: The removed domain permission exclude. +// schema: +// "$ref": "#/definitions/domainPermission" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '406': +// description: not acceptable +// '409': +// description: conflict +// '500': +// description: internal server error +func (m *Module) DomainPermissionExcludeDELETEHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if !*authed.User.Admin { + err := fmt.Errorf("user %s not an admin", authed.User.ID) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if authed.Account.IsMoving() { + apiutil.ForbiddenAfterMove(c) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + domainPerm, errWithCode := m.processor.Admin().DomainPermissionExcludeRemove( + c.Request.Context(), + authed.Account, + id, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, domainPerm) +} diff --git a/internal/api/client/admin/domainpermissionexcludesget.go b/internal/api/client/admin/domainpermissionexcludesget.go new file mode 100644 index 000000000..71eedec52 --- /dev/null +++ b/internal/api/client/admin/domainpermissionexcludesget.go @@ -0,0 +1,159 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package admin + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/paging" +) + +// DomainPermissionExcludesGETHandler swagger:operation GET /api/v1/admin/domain_permission_excludes domainPermissionExcludesGet +// +// View domain permission excludes. +// +// The excludes will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer). +// +// The next and previous queries can be parsed from the returned Link header. +// +// Example: +// +// ``` +// <https://example.org/api/v1/admin/domain_permission_excludes?limit=20&max_id=01FC0SKA48HNSVR6YKZCQGS2V8>; rel="next", <https://example.org/api/v1/admin/domain_permission_excludes?limit=20&min_id=01FC0SKW5JK2Q4EVAV2B462YY0>; rel="prev" +// ```` +// +// --- +// tags: +// - admin +// +// produces: +// - application/json +// +// parameters: +// - +// name: domain +// type: string +// description: Return only excludes that target the given domain. +// in: query +// - +// name: max_id +// type: string +// description: >- +// Return only items *OLDER* than the given max ID (for paging downwards). +// The item with the specified ID will not be included in the response. +// in: query +// - +// name: since_id +// type: string +// description: >- +// Return only items *NEWER* than the given since ID. +// The item with the specified ID will not be included in the response. +// in: query +// - +// name: min_id +// type: string +// description: >- +// Return only items immediately *NEWER* than the given min ID (for paging upwards). +// The item with the specified ID will not be included in the response. +// in: query +// - +// name: limit +// type: integer +// description: Number of items to return. +// default: 20 +// minimum: 1 +// maximum: 100 +// in: query +// +// security: +// - OAuth2 Bearer: +// - admin +// +// responses: +// '200': +// description: Domain permission excludes. +// schema: +// type: array +// items: +// "$ref": "#/definitions/domainPermission" +// headers: +// Link: +// type: string +// description: Links to the next and previous queries. +// '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) DomainPermissionExcludesGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if !*authed.User.Admin { + err := fmt.Errorf("user %s not an admin", authed.User.ID) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if authed.Account.IsMoving() { + apiutil.ForbiddenAfterMove(c) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + page, errWithCode := paging.ParseIDPage(c, 1, 200, 20) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + resp, errWithCode := m.processor.Admin().DomainPermissionExcludesGet( + c.Request.Context(), + c.Query(apiutil.DomainPermissionDomainKey), + page, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + if resp.LinkHeader != "" { + c.Header("Link", resp.LinkHeader) + } + + apiutil.JSON(c, http.StatusOK, resp.Items) +} diff --git a/internal/api/model/domain.go b/internal/api/model/domain.go index ddc96ef05..c973c7d92 100644 --- a/internal/api/model/domain.go +++ b/internal/api/model/domain.go @@ -61,6 +61,9 @@ type DomainPermission struct { // 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"` + // Permission type of this entry (block, allow). + // Only set for domain permission drafts. + PermissionType string `json:"permission_type,omitempty"` } // DomainPermissionRequest is the form submitted as a POST to create a new domain permission entry (allow/block). @@ -69,22 +72,24 @@ type DomainPermission struct { 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"` + Domains *multipart.FileHeader `form:"domains" json:"domains"` // 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"` + Domain string `form:"domain" json:"domain"` // 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"` + Obfuscate bool `form:"obfuscate" json:"obfuscate"` // 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"` + PrivateComment string `form:"private_comment" json:"private_comment"` // 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"` + PublicComment string `form:"public_comment" json:"public_comment"` + // Permission type to create (only applies to domain permission drafts, not explicit blocks and allows). + PermissionType string `form:"permission_type" json:"permission_type"` } // DomainKeysExpireRequest is the form submitted as a POST to /api/v1/admin/domain_keys_expire to expire a domain's public keys. @@ -92,5 +97,5 @@ type DomainPermissionRequest struct { // swagger:parameters domainKeysExpire type DomainKeysExpireRequest struct { // hostname/domain to expire keys for. - Domain string `form:"domain" json:"domain" xml:"domain"` + Domain string `form:"domain" json:"domain"` } diff --git a/internal/api/util/parsequery.go b/internal/api/util/parsequery.go index 024ec028b..9f4c02aed 100644 --- a/internal/api/util/parsequery.go +++ b/internal/api/util/parsequery.go @@ -69,8 +69,11 @@ const ( /* Domain permission keys */ - DomainPermissionExportKey = "export" - DomainPermissionImportKey = "import" + DomainPermissionExportKey = "export" + DomainPermissionImportKey = "import" + DomainPermissionSubscriptionIDKey = "subscription_id" + DomainPermissionPermTypeKey = "permission_type" + DomainPermissionDomainKey = "domain" /* Admin query keys */ diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 09e505ff5..a4f9f2044 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -74,6 +74,8 @@ func (c *Caches) Init() { c.initConversationLastStatusIDs() c.initDomainAllow() c.initDomainBlock() + c.initDomainPermissionDraft() + c.initDomainPermissionExclude() c.initEmoji() c.initEmojiCategory() c.initFilter() diff --git a/internal/cache/db.go b/internal/cache/db.go index dd4e8b212..aac11236a 100644 --- a/internal/cache/db.go +++ b/internal/cache/db.go @@ -67,6 +67,12 @@ type DBCaches struct { // DomainBlock provides access to the domain block database cache. DomainBlock *domain.Cache + // DomainPermissionDraft provides access to the domain permission draft database cache. + DomainPermissionDraft StructCache[*gtsmodel.DomainPermissionDraft] + + // DomainPermissionExclude provides access to the domain permission exclude database cache. + DomainPermissionExclude *domain.Cache + // Emoji provides access to the gtsmodel Emoji database cache. Emoji StructCache[*gtsmodel.Emoji] @@ -548,6 +554,42 @@ func (c *Caches) initDomainBlock() { c.DB.DomainBlock = new(domain.Cache) } +func (c *Caches) initDomainPermissionDraft() { + // Calculate maximum cache size. + cap := calculateResultCacheMax( + sizeofDomainPermissionDraft(), // model in-mem size. + config.GetCacheDomainPermissionDraftMemRation(), + ) + + log.Infof(nil, "cache size = %d", cap) + + copyF := func(d1 *gtsmodel.DomainPermissionDraft) *gtsmodel.DomainPermissionDraft { + d2 := new(gtsmodel.DomainPermissionDraft) + *d2 = *d1 + + // Don't include ptr fields that + // will be populated separately. + d2.CreatedByAccount = nil + + return d2 + } + + c.DB.DomainPermissionDraft.Init(structr.CacheConfig[*gtsmodel.DomainPermissionDraft]{ + Indices: []structr.IndexConfig{ + {Fields: "ID"}, + {Fields: "Domain", Multiple: true}, + {Fields: "SubscriptionID", Multiple: true}, + }, + MaxSize: cap, + IgnoreErr: ignoreErrors, + Copy: copyF, + }) +} + +func (c *Caches) initDomainPermissionExclude() { + c.DB.DomainPermissionExclude = new(domain.Cache) +} + func (c *Caches) initEmoji() { // Calculate maximum cache size. cap := calculateResultCacheMax( diff --git a/internal/cache/size.go b/internal/cache/size.go index 8367e4c46..26f4096ed 100644 --- a/internal/cache/size.go +++ b/internal/cache/size.go @@ -342,6 +342,21 @@ func sizeofConversation() uintptr { })) } +func sizeofDomainPermissionDraft() uintptr { + return uintptr(size.Of(>smodel.DomainPermissionDraft{ + ID: exampleID, + CreatedAt: exampleTime, + UpdatedAt: exampleTime, + PermissionType: gtsmodel.DomainPermissionBlock, + Domain: "example.org", + CreatedByAccountID: exampleID, + PrivateComment: exampleTextSmall, + PublicComment: exampleTextSmall, + Obfuscate: util.Ptr(false), + SubscriptionID: exampleID, + })) +} + func sizeofEmoji() uintptr { return uintptr(size.Of(>smodel.Emoji{ ID: exampleID, diff --git a/internal/config/config.go b/internal/config/config.go index 9001b61d0..2e3ad8ec1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -208,6 +208,7 @@ type CacheConfiguration struct { ClientMemRatio float64 `name:"client-mem-ratio"` ConversationMemRatio float64 `name:"conversation-mem-ratio"` ConversationLastStatusIDsMemRatio float64 `name:"conversation-last-status-ids-mem-ratio"` + DomainPermissionDraftMemRation float64 `name:"domain-permission-draft-mem-ratio"` EmojiMemRatio float64 `name:"emoji-mem-ratio"` EmojiCategoryMemRatio float64 `name:"emoji-category-mem-ratio"` FilterMemRatio float64 `name:"filter-mem-ratio"` diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 48d880e1b..9b45002d0 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -169,6 +169,7 @@ var Defaults = Configuration{ ClientMemRatio: 0.1, ConversationMemRatio: 1, ConversationLastStatusIDsMemRatio: 2, + DomainPermissionDraftMemRation: 0.5, EmojiMemRatio: 3, EmojiCategoryMemRatio: 0.1, FilterMemRatio: 0.5, diff --git a/internal/config/helpers.gen.go b/internal/config/helpers.gen.go index 2a7e5b6ad..a35622f8e 100644 --- a/internal/config/helpers.gen.go +++ b/internal/config/helpers.gen.go @@ -3156,6 +3156,37 @@ func SetCacheConversationLastStatusIDsMemRatio(v float64) { global.SetCacheConversationLastStatusIDsMemRatio(v) } +// GetCacheDomainPermissionDraftMemRation safely fetches the Configuration value for state's 'Cache.DomainPermissionDraftMemRation' field +func (st *ConfigState) GetCacheDomainPermissionDraftMemRation() (v float64) { + st.mutex.RLock() + v = st.config.Cache.DomainPermissionDraftMemRation + st.mutex.RUnlock() + return +} + +// SetCacheDomainPermissionDraftMemRation safely sets the Configuration value for state's 'Cache.DomainPermissionDraftMemRation' field +func (st *ConfigState) SetCacheDomainPermissionDraftMemRation(v float64) { + st.mutex.Lock() + defer st.mutex.Unlock() + st.config.Cache.DomainPermissionDraftMemRation = v + st.reloadToViper() +} + +// CacheDomainPermissionDraftMemRationFlag returns the flag name for the 'Cache.DomainPermissionDraftMemRation' field +func CacheDomainPermissionDraftMemRationFlag() string { + return "cache-domain-permission-draft-mem-ratio" +} + +// GetCacheDomainPermissionDraftMemRation safely fetches the value for global configuration 'Cache.DomainPermissionDraftMemRation' field +func GetCacheDomainPermissionDraftMemRation() float64 { + return global.GetCacheDomainPermissionDraftMemRation() +} + +// SetCacheDomainPermissionDraftMemRation safely sets the value for global configuration 'Cache.DomainPermissionDraftMemRation' field +func SetCacheDomainPermissionDraftMemRation(v float64) { + global.SetCacheDomainPermissionDraftMemRation(v) +} + // GetCacheEmojiMemRatio safely fetches the Configuration value for state's 'Cache.EmojiMemRatio' field func (st *ConfigState) GetCacheEmojiMemRatio() (v float64) { st.mutex.RLock() diff --git a/internal/db/bundb/domain.go b/internal/db/bundb/domain.go index 0d2a13b34..fd75fec4c 100644 --- a/internal/db/bundb/domain.go +++ b/internal/db/bundb/domain.go @@ -20,6 +20,7 @@ package bundb import ( "context" "net/url" + "time" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" @@ -110,6 +111,36 @@ func (d *domainDB) GetDomainAllowByID(ctx context.Context, id string) (*gtsmodel return &allow, nil } +func (d *domainDB) UpdateDomainAllow(ctx context.Context, allow *gtsmodel.DomainAllow, columns ...string) error { + // Normalize the domain as punycode + var err error + allow.Domain, err = util.Punify(allow.Domain) + if err != nil { + return err + } + + // Ensure updated_at is set. + allow.UpdatedAt = time.Now() + if len(columns) != 0 { + columns = append(columns, "updated_at") + } + + // Attempt to update domain allow. + if _, err := d.db. + NewUpdate(). + Model(allow). + Column(columns...). + Where("? = ?", bun.Ident("domain_allow.id"), allow.ID). + Exec(ctx); err != nil { + return err + } + + // Clear the domain allow cache (for later reload) + d.state.Caches.DB.DomainAllow.Clear() + + return nil +} + func (d *domainDB) DeleteDomainAllow(ctx context.Context, domain string) error { // Normalize the domain as punycode domain, err := util.Punify(domain) @@ -206,6 +237,36 @@ func (d *domainDB) GetDomainBlockByID(ctx context.Context, id string) (*gtsmodel return &block, nil } +func (d *domainDB) UpdateDomainBlock(ctx context.Context, block *gtsmodel.DomainBlock, columns ...string) error { + // Normalize the domain as punycode + var err error + block.Domain, err = util.Punify(block.Domain) + if err != nil { + return err + } + + // Ensure updated_at is set. + block.UpdatedAt = time.Now() + if len(columns) != 0 { + columns = append(columns, "updated_at") + } + + // Attempt to update domain block. + if _, err := d.db. + NewUpdate(). + Model(block). + Column(columns...). + Where("? = ?", bun.Ident("domain_block.id"), block.ID). + Exec(ctx); err != nil { + return err + } + + // Clear the domain block cache (for later reload) + d.state.Caches.DB.DomainBlock.Clear() + + return nil +} + func (d *domainDB) DeleteDomainBlock(ctx context.Context, domain string) error { // Normalize the domain as punycode domain, err := util.Punify(domain) diff --git a/internal/db/bundb/domainpermissiondraft.go b/internal/db/bundb/domainpermissiondraft.go new file mode 100644 index 000000000..dec41e9d7 --- /dev/null +++ b/internal/db/bundb/domainpermissiondraft.go @@ -0,0 +1,285 @@ +// 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 bundb + +import ( + "context" + "errors" + "slices" + + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtscontext" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/paging" + "github.com/superseriousbusiness/gotosocial/internal/util" + "github.com/uptrace/bun" +) + +func (d *domainDB) getDomainPermissionDraft( + ctx context.Context, + lookup string, + dbQuery func(*gtsmodel.DomainPermissionDraft) error, + keyParts ...any, +) (*gtsmodel.DomainPermissionDraft, error) { + // Fetch perm draft from database cache with loader callback. + permDraft, err := d.state.Caches.DB.DomainPermissionDraft.LoadOne( + lookup, + // Only called if not cached. + func() (*gtsmodel.DomainPermissionDraft, error) { + var permDraft gtsmodel.DomainPermissionDraft + if err := dbQuery(&permDraft); err != nil { + return nil, err + } + return &permDraft, nil + }, + keyParts..., + ) + if err != nil { + return nil, err + } + + if gtscontext.Barebones(ctx) { + // No need to fully populate. + return permDraft, nil + } + + if permDraft.CreatedByAccount == nil { + // Not set, fetch from database. + permDraft.CreatedByAccount, err = d.state.DB.GetAccountByID( + gtscontext.SetBarebones(ctx), + permDraft.CreatedByAccountID, + ) + if err != nil { + return nil, gtserror.Newf("error populating created by account: %w", err) + } + } + + return permDraft, nil +} + +func (d *domainDB) GetDomainPermissionDraftByID( + ctx context.Context, + id string, +) (*gtsmodel.DomainPermissionDraft, error) { + return d.getDomainPermissionDraft( + ctx, + "ID", + func(permDraft *gtsmodel.DomainPermissionDraft) error { + return d.db. + NewSelect(). + Model(permDraft). + Where("? = ?", bun.Ident("domain_permission_draft.id"), id). + Scan(ctx) + }, + id, + ) +} + +func (d *domainDB) GetDomainPermissionDrafts( + ctx context.Context, + permType gtsmodel.DomainPermissionType, + permSubID string, + domain string, + page *paging.Page, +) ( + []*gtsmodel.DomainPermissionDraft, + error, +) { + var ( + // Get paging params. + minID = page.GetMin() + maxID = page.GetMax() + limit = page.GetLimit() + order = page.GetOrder() + + // Make educated guess for slice size + permDraftIDs = make([]string, 0, limit) + ) + + q := d.db. + NewSelect(). + TableExpr( + "? AS ?", + bun.Ident("domain_permission_drafts"), + bun.Ident("domain_permission_draft"), + ). + // Select only IDs from table + Column("domain_permission_draft.id") + + // Return only items with id + // lower than provided maxID. + if maxID != "" { + q = q.Where( + "? < ?", + bun.Ident("domain_permission_draft.id"), + maxID, + ) + } + + // Return only items with id + // greater than provided minID. + if minID != "" { + q = q.Where( + "? > ?", + bun.Ident("domain_permission_draft.id"), + minID, + ) + } + + // Return only items with + // given permission type. + if permType != gtsmodel.DomainPermissionUnknown { + q = q.Where( + "? = ?", + bun.Ident("domain_permission_draft.permission_type"), + permType, + ) + } + + // Return only items with + // given subscription ID. + if permSubID != "" { + q = q.Where( + "? = ?", + bun.Ident("domain_permission_draft.subscription_id"), + permSubID, + ) + } + + // Return only items + // with given domain. + if domain != "" { + var err error + + // Normalize domain as punycode. + domain, err = util.Punify(domain) + if err != nil { + return nil, gtserror.Newf("error punifying domain %s: %w", domain, err) + } + + q = q.Where( + "? = ?", + bun.Ident("domain_permission_draft.domain"), + domain, + ) + } + + if limit > 0 { + // Limit amount of + // items returned. + q = q.Limit(limit) + } + + if order == paging.OrderAscending { + // Page up. + q = q.OrderExpr( + "? ASC", + bun.Ident("domain_permission_draft.id"), + ) + } else { + // Page down. + q = q.OrderExpr( + "? DESC", + bun.Ident("domain_permission_draft.id"), + ) + } + + if err := q.Scan(ctx, &permDraftIDs); err != nil { + return nil, err + } + + // Catch case of no items early + if len(permDraftIDs) == 0 { + return nil, db.ErrNoEntries + } + + // If we're paging up, we still want items + // to be sorted by ID desc, so reverse slice. + if order == paging.OrderAscending { + slices.Reverse(permDraftIDs) + } + + // Allocate return slice (will be at most len permDraftIDs) + permDrafts := make([]*gtsmodel.DomainPermissionDraft, 0, len(permDraftIDs)) + for _, id := range permDraftIDs { + permDraft, err := d.GetDomainPermissionDraftByID(ctx, id) + if err != nil { + log.Errorf(ctx, "error getting domain permission draft %q: %v", id, err) + continue + } + + // Append to return slice + permDrafts = append(permDrafts, permDraft) + } + + return permDrafts, nil +} + +func (d *domainDB) PutDomainPermissionDraft( + ctx context.Context, + permDraft *gtsmodel.DomainPermissionDraft, +) error { + var err error + + // Normalize the domain as punycode + permDraft.Domain, err = util.Punify(permDraft.Domain) + if err != nil { + return gtserror.Newf("error punifying domain %s: %w", permDraft.Domain, err) + } + + return d.state.Caches.DB.DomainPermissionDraft.Store( + permDraft, + func() error { + _, err := d.db. + NewInsert(). + Model(permDraft). + Exec(ctx) + return err + }, + ) +} + +func (d *domainDB) DeleteDomainPermissionDraft( + ctx context.Context, + id string, +) error { + // Delete the permDraft from DB. + q := d.db.NewDelete(). + TableExpr( + "? AS ?", + bun.Ident("domain_permission_drafts"), + bun.Ident("domain_permission_draft"), + ). + Where( + "? = ?", + bun.Ident("domain_permission_draft.id"), + id, + ) + + _, err := q.Exec(ctx) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return err + } + + // Invalidate any cached model by ID. + d.state.Caches.DB.DomainPermissionDraft.Invalidate("ID", id) + + return nil +} diff --git a/internal/db/bundb/domainpermissiondraft_test.go b/internal/db/bundb/domainpermissiondraft_test.go new file mode 100644 index 000000000..e4f255483 --- /dev/null +++ b/internal/db/bundb/domainpermissiondraft_test.go @@ -0,0 +1,120 @@ +// 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 bundb_test + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +type DomainPermissionDraftTestSuite struct { + BunDBStandardTestSuite +} + +func (suite *DomainPermissionDraftTestSuite) TestPermDraftCreateGetDelete() { + var ( + ctx = context.Background() + draft = >smodel.DomainPermissionDraft{ + ID: "01JCZN614XG85GCGAMSV9ZZAEJ", + PermissionType: gtsmodel.DomainPermissionBlock, + Domain: "exämple.org", + CreatedByAccountID: suite.testAccounts["admin_account"].ID, + PrivateComment: "this domain is poo", + PublicComment: "this domain is poo, but phrased in a more outward-facing way", + Obfuscate: util.Ptr(false), + SubscriptionID: "01JCZN8PG55KKEVTDAY52D0T3P", + } + ) + + // Whack the draft in. + if err := suite.state.DB.PutDomainPermissionDraft(ctx, draft); err != nil { + suite.FailNow(err.Error()) + } + + // Get the draft again. + dbDraft, err := suite.state.DB.GetDomainPermissionDraftByID(ctx, draft.ID) + if err != nil { + suite.FailNow(err.Error()) + } + + // Domain should have been stored punycoded. + suite.Equal("xn--exmple-cua.org", dbDraft.Domain) + + // Search for domain using both + // punycode and unicode variants. + search1, err := suite.state.DB.GetDomainPermissionDrafts( + ctx, + gtsmodel.DomainPermissionUnknown, + "", + "exämple.org", + nil, + ) + if err != nil { + suite.FailNow(err.Error()) + } + if len(search1) != 1 { + suite.FailNow("couldn't get domain perm draft exämple.org") + } + + search2, err := suite.state.DB.GetDomainPermissionDrafts( + ctx, + gtsmodel.DomainPermissionUnknown, + "", + "xn--exmple-cua.org", + nil, + ) + if err != nil { + suite.FailNow(err.Error()) + } + if len(search2) != 1 { + suite.FailNow("couldn't get domain perm draft example.org") + } + + // Change ID + try to put the same draft again. + draft.ID = "01JCZNVYSDT3JE385FABMJ7ADQ" + err = suite.state.DB.PutDomainPermissionDraft(ctx, draft) + if !errors.Is(err, db.ErrAlreadyExists) { + suite.FailNow("was able to insert same domain perm draft twice") + } + + // Put same draft but change permission type, should work. + draft.PermissionType = gtsmodel.DomainPermissionAllow + if err := suite.state.DB.PutDomainPermissionDraft(ctx, draft); err != nil { + suite.FailNow(err.Error()) + } + + // Delete both drafts. + for _, id := range []string{ + "01JCZN614XG85GCGAMSV9ZZAEJ", + "01JCZNVYSDT3JE385FABMJ7ADQ", + } { + if err := suite.state.DB.DeleteDomainPermissionDraft(ctx, id); err != nil { + suite.FailNow("error deleting domain permission draft") + } + } +} + +func TestDomainPermissionDraftTestSuite(t *testing.T) { + suite.Run(t, new(DomainPermissionDraftTestSuite)) +} diff --git a/internal/db/bundb/domainpermissionexclude.go b/internal/db/bundb/domainpermissionexclude.go new file mode 100644 index 000000000..005c21400 --- /dev/null +++ b/internal/db/bundb/domainpermissionexclude.go @@ -0,0 +1,270 @@ +// 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 bundb + +import ( + "context" + "errors" + "slices" + + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtscontext" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/paging" + "github.com/superseriousbusiness/gotosocial/internal/util" + "github.com/uptrace/bun" +) + +func (d *domainDB) PutDomainPermissionExclude( + ctx context.Context, + exclude *gtsmodel.DomainPermissionExclude, +) error { + // Normalize the domain as punycode + var err error + exclude.Domain, err = util.Punify(exclude.Domain) + if err != nil { + return err + } + + // Attempt to store domain perm exclude in DB + if _, err := d.db.NewInsert(). + Model(exclude). + Exec(ctx); err != nil { + return err + } + + // Clear the domain perm exclude cache (for later reload) + d.state.Caches.DB.DomainPermissionExclude.Clear() + + return nil +} + +func (d *domainDB) IsDomainPermissionExcluded(ctx context.Context, domain string) (bool, error) { + // Normalize the domain as punycode + domain, err := util.Punify(domain) + if err != nil { + return false, err + } + + // Func to scan list of all + // excluded domain perms from DB. + loadF := func() ([]string, error) { + var domains []string + + if err := d.db. + NewSelect(). + Table("domain_permission_excludes"). + Column("domain"). + Scan(ctx, &domains); err != nil { + return nil, err + } + + // Exclude our own domain as creating blocks + // or allows for self will likely break things. + domains = append(domains, config.GetHost()) + + return domains, nil + } + + // Check the cache for a domain perm exclude, + // hydrating the cache with loadF if necessary. + return d.state.Caches.DB.DomainPermissionExclude.Matches(domain, loadF) +} + +func (d *domainDB) GetDomainPermissionExcludeByID( + ctx context.Context, + id string, +) (*gtsmodel.DomainPermissionExclude, error) { + exclude := new(gtsmodel.DomainPermissionExclude) + + q := d.db. + NewSelect(). + Model(exclude). + Where("? = ?", bun.Ident("domain_permission_exclude.id"), id) + if err := q.Scan(ctx); err != nil { + return nil, err + } + + if gtscontext.Barebones(ctx) { + // No need to fully populate. + return exclude, nil + } + + if exclude.CreatedByAccount == nil { + // Not set, fetch from database. + var err error + exclude.CreatedByAccount, err = d.state.DB.GetAccountByID( + gtscontext.SetBarebones(ctx), + exclude.CreatedByAccountID, + ) + if err != nil { + return nil, gtserror.Newf("error populating created by account: %w", err) + } + } + + return exclude, nil +} + +func (d *domainDB) GetDomainPermissionExcludes( + ctx context.Context, + domain string, + page *paging.Page, +) ( + []*gtsmodel.DomainPermissionExclude, + error, +) { + var ( + // Get paging params. + minID = page.GetMin() + maxID = page.GetMax() + limit = page.GetLimit() + order = page.GetOrder() + + // Make educated guess for slice size + excludeIDs = make([]string, 0, limit) + ) + + q := d.db. + NewSelect(). + TableExpr( + "? AS ?", + bun.Ident("domain_permission_excludes"), + bun.Ident("domain_permission_exclude"), + ). + // Select only IDs from table + Column("domain_permission_exclude.id") + + // Return only items with id + // lower than provided maxID. + if maxID != "" { + q = q.Where( + "? < ?", + bun.Ident("domain_permission_exclude.id"), + maxID, + ) + } + + // Return only items with id + // greater than provided minID. + if minID != "" { + q = q.Where( + "? > ?", + bun.Ident("domain_permission_exclude.id"), + minID, + ) + } + + // Return only items + // with given domain. + if domain != "" { + var err error + + // Normalize domain as punycode. + domain, err = util.Punify(domain) + if err != nil { + return nil, gtserror.Newf("error punifying domain %s: %w", domain, err) + } + + q = q.Where( + "? = ?", + bun.Ident("domain_permission_exclude.domain"), + domain, + ) + } + + if limit > 0 { + // Limit amount of + // items returned. + q = q.Limit(limit) + } + + if order == paging.OrderAscending { + // Page up. + q = q.OrderExpr( + "? ASC", + bun.Ident("domain_permission_exclude.id"), + ) + } else { + // Page down. + q = q.OrderExpr( + "? DESC", + bun.Ident("domain_permission_exclude.id"), + ) + } + + if err := q.Scan(ctx, &excludeIDs); err != nil { + return nil, err + } + + // Catch case of no items early + if len(excludeIDs) == 0 { + return nil, db.ErrNoEntries + } + + // If we're paging up, we still want items + // to be sorted by ID desc, so reverse slice. + if order == paging.OrderAscending { + slices.Reverse(excludeIDs) + } + + // Allocate return slice (will be at most len permSubIDs). + excludes := make([]*gtsmodel.DomainPermissionExclude, 0, len(excludeIDs)) + for _, id := range excludeIDs { + exclude, err := d.GetDomainPermissionExcludeByID(ctx, id) + if err != nil { + log.Errorf(ctx, "error getting domain permission exclude %q: %v", id, err) + continue + } + + // Append to return slice + excludes = append(excludes, exclude) + } + + return excludes, nil +} + +func (d *domainDB) DeleteDomainPermissionExclude( + ctx context.Context, + id string, +) error { + // Delete the permSub from DB. + q := d.db.NewDelete(). + TableExpr( + "? AS ?", + bun.Ident("domain_permission_excludes"), + bun.Ident("domain_permission_exclude"), + ). + Where( + "? = ?", + bun.Ident("domain_permission_exclude.id"), + id, + ) + + _, err := q.Exec(ctx) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return err + } + + // Clear the domain perm exclude cache (for later reload) + d.state.Caches.DB.DomainPermissionExclude.Clear() + + return nil +} diff --git a/internal/db/bundb/domainpermissionexclude_test.go b/internal/db/bundb/domainpermissionexclude_test.go new file mode 100644 index 000000000..47af710f9 --- /dev/null +++ b/internal/db/bundb/domainpermissionexclude_test.go @@ -0,0 +1,185 @@ +// 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 bundb_test + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +type DomainPermissionExcludeTestSuite struct { + BunDBStandardTestSuite +} + +func (suite *DomainPermissionExcludeTestSuite) TestPermExcludeCreateGetDelete() { + var ( + ctx = context.Background() + exclude = >smodel.DomainPermissionExclude{ + ID: "01JCZN614XG85GCGAMSV9ZZAEJ", + Domain: "exämple.org", + CreatedByAccountID: suite.testAccounts["admin_account"].ID, + PrivateComment: "this domain is poo", + } + ) + + // Whack the exclude in. + if err := suite.state.DB.PutDomainPermissionExclude(ctx, exclude); err != nil { + suite.FailNow(err.Error()) + } + + // Get the exclude again. + dbExclude, err := suite.state.DB.GetDomainPermissionExcludeByID(ctx, exclude.ID) + if err != nil { + suite.FailNow(err.Error()) + } + + // Domain should have been stored punycoded. + suite.Equal("xn--exmple-cua.org", dbExclude.Domain) + + // Search for domain using both + // punycode and unicode variants. + search1, err := suite.state.DB.GetDomainPermissionExcludes( + ctx, + "exämple.org", + nil, + ) + if err != nil { + suite.FailNow(err.Error()) + } + if len(search1) != 1 { + suite.FailNow("couldn't get domain perm exclude exämple.org") + } + + search2, err := suite.state.DB.GetDomainPermissionExcludes( + ctx, + "xn--exmple-cua.org", + nil, + ) + if err != nil { + suite.FailNow(err.Error()) + } + if len(search2) != 1 { + suite.FailNow("couldn't get domain perm exclude example.org") + } + + // Change ID + try to put the same exclude again. + exclude.ID = "01JCZNVYSDT3JE385FABMJ7ADQ" + err = suite.state.DB.PutDomainPermissionExclude(ctx, exclude) + if !errors.Is(err, db.ErrAlreadyExists) { + suite.FailNow("was able to insert same domain perm exclude twice") + } + + // Delete both excludes. + for _, id := range []string{ + "01JCZN614XG85GCGAMSV9ZZAEJ", + "01JCZNVYSDT3JE385FABMJ7ADQ", + } { + if err := suite.state.DB.DeleteDomainPermissionExclude(ctx, id); err != nil { + suite.FailNow("error deleting domain permission exclude") + } + } +} + +func (suite *DomainPermissionExcludeTestSuite) TestExcluded() { + var ( + ctx = context.Background() + createdByAccountID = suite.testAccounts["admin_account"].ID + ) + + // Insert some excludes into the db. + for _, exclude := range []*gtsmodel.DomainPermissionExclude{ + { + ID: "01JD7AFFBBZSPY8R2M0JCGQGPW", + Domain: "example.org", + CreatedByAccountID: createdByAccountID, + }, + { + ID: "01JD7AMK98E2QX78KXEZJ1RF5Z", + Domain: "boobs.com", + CreatedByAccountID: createdByAccountID, + }, + { + ID: "01JD7AMXW3R3W98E91R62ACDA0", + Domain: "rad.boobs.com", + CreatedByAccountID: createdByAccountID, + }, + { + ID: "01JD7AYYN5TXQVASB30PT08CE1", + Domain: "honkers.org", + CreatedByAccountID: createdByAccountID, + }, + } { + if err := suite.state.DB.PutDomainPermissionExclude(ctx, exclude); err != nil { + suite.FailNow(err.Error()) + } + } + + type testCase struct { + domain string + excluded bool + } + + for i, testCase := range []testCase{ + { + domain: config.GetHost(), + excluded: true, + }, + { + domain: "test.example.org", + excluded: true, + }, + { + domain: "example.org", + excluded: true, + }, + { + domain: "boobs.com", + excluded: true, + }, + { + domain: "rad.boobs.com", + excluded: true, + }, + { + domain: "sir.not.appearing.in.this.list", + excluded: false, + }, + } { + excluded, err := suite.state.DB.IsDomainPermissionExcluded(ctx, testCase.domain) + if err != nil { + suite.FailNow(err.Error()) + } + + if excluded != testCase.excluded { + suite.Failf("", + "test %d: %s excluded should be %t", + i, testCase.domain, testCase.excluded, + ) + } + } +} + +func TestDomainPermissionExcludeTestSuite(t *testing.T) { + suite.Run(t, new(DomainPermissionExcludeTestSuite)) +} diff --git a/internal/db/bundb/migrations/20241022153016_domain_permission_draft_exclude.go b/internal/db/bundb/migrations/20241022153016_domain_permission_draft_exclude.go new file mode 100644 index 000000000..e19ea2b4d --- /dev/null +++ b/internal/db/bundb/migrations/20241022153016_domain_permission_draft_exclude.go @@ -0,0 +1,82 @@ +// 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" + + "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_permission_drafts`. + if _, err := tx. + NewCreateTable(). + Model((*gtsmodel.DomainPermissionDraft)(nil)). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + + // Create `domain_permission_ignores`. + if _, err := tx. + NewCreateTable(). + Model((*gtsmodel.DomainPermissionExclude)(nil)). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + + // Create indexes. Indices. Indie sexes. + for table, indexes := range map[string]map[string][]string{ + "domain_permission_drafts": { + "domain_permission_drafts_domain_idx": {"domain"}, + "domain_permission_drafts_subscription_id_idx": {"subscription_id"}, + }, + } { + for index, columns := range indexes { + if _, err := tx. + NewCreateIndex(). + Table(table). + Index(index). + Column(columns...). + IfNotExists(). + 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 3f7803d62..f4d05ad1d 100644 --- a/internal/db/domain.go +++ b/internal/db/domain.go @@ -22,6 +22,7 @@ import ( "net/url" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/paging" ) // Domain contains DB functions related to domains and domain blocks. @@ -42,6 +43,9 @@ type Domain interface { // GetDomainAllows returns all instance-level domain allows currently enforced by this instance. GetDomainAllows(ctx context.Context) ([]*gtsmodel.DomainAllow, error) + // UpdateDomainAllow updates the given domain allow, setting the provided columns (empty for all). + UpdateDomainAllow(ctx context.Context, allow *gtsmodel.DomainAllow, columns ...string) error + // DeleteDomainAllow deletes an instance-level domain allow with the given domain, if it exists. DeleteDomainAllow(ctx context.Context, domain string) error @@ -57,6 +61,9 @@ type Domain interface { // GetDomainBlocks returns all instance-level domain blocks currently enforced by this instance. GetDomainBlocks(ctx context.Context) ([]*gtsmodel.DomainBlock, error) + // UpdateDomainBlock updates the given domain block, setting the provided columns (empty for all). + UpdateDomainBlock(ctx context.Context, block *gtsmodel.DomainBlock, columns ...string) error + // DeleteDomainBlock deletes an instance-level domain block with the given domain, if it exists. DeleteDomainBlock(ctx context.Context, domain string) error @@ -78,4 +85,51 @@ type Domain interface { // 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) + + /* + Domain permission draft stuff. + */ + + // GetDomainPermissionDraftByID gets one DomainPermissionDraft with the given ID. + GetDomainPermissionDraftByID(ctx context.Context, id string) (*gtsmodel.DomainPermissionDraft, error) + + // GetDomainPermissionDrafts returns a page of + // DomainPermissionDrafts using the given parameters. + GetDomainPermissionDrafts( + ctx context.Context, + permType gtsmodel.DomainPermissionType, + permSubID string, + domain string, + page *paging.Page, + ) ([]*gtsmodel.DomainPermissionDraft, error) + + // PutDomainPermissionDraft stores one DomainPermissionDraft. + PutDomainPermissionDraft(ctx context.Context, permDraft *gtsmodel.DomainPermissionDraft) error + + // DeleteDomainPermissionDraft deletes one DomainPermissionDraft with the given id. + DeleteDomainPermissionDraft(ctx context.Context, id string) error + + /* + Domain permission exclude stuff. + */ + + // GetDomainPermissionExcludeByID gets one DomainPermissionExclude with the given ID. + GetDomainPermissionExcludeByID(ctx context.Context, id string) (*gtsmodel.DomainPermissionExclude, error) + + // GetDomainPermissionExcludes returns a page of + // DomainPermissionExcludes using the given parameters. + GetDomainPermissionExcludes( + ctx context.Context, + domain string, + page *paging.Page, + ) ([]*gtsmodel.DomainPermissionExclude, error) + + // PutDomainPermissionExclude stores one DomainPermissionExclude. + PutDomainPermissionExclude(ctx context.Context, permExclude *gtsmodel.DomainPermissionExclude) error + + // DeleteDomainPermissionExclude deletes one DomainPermissionExclude with the given id. + DeleteDomainPermissionExclude(ctx context.Context, id string) error + + // IsDomainPermissionExcluded returns true if the given domain matches in the list of excluded domains. + IsDomainPermissionExcluded(ctx context.Context, domain string) (bool, error) } diff --git a/internal/gtsmodel/domainallow.go b/internal/gtsmodel/domainallow.go index 2a3e53e79..7b947333b 100644 --- a/internal/gtsmodel/domainallow.go +++ b/internal/gtsmodel/domainallow.go @@ -45,6 +45,10 @@ func (d *DomainAllow) GetUpdatedAt() time.Time { return d.UpdatedAt } +func (d *DomainAllow) SetUpdatedAt(i time.Time) { + d.UpdatedAt = i +} + func (d *DomainAllow) GetDomain() string { return d.Domain } @@ -53,26 +57,50 @@ func (d *DomainAllow) GetCreatedByAccountID() string { return d.CreatedByAccountID } +func (d *DomainAllow) SetCreatedByAccountID(i string) { + d.CreatedByAccountID = i +} + func (d *DomainAllow) GetCreatedByAccount() *Account { return d.CreatedByAccount } +func (d *DomainAllow) SetCreatedByAccount(i *Account) { + d.CreatedByAccount = i +} + func (d *DomainAllow) GetPrivateComment() string { return d.PrivateComment } +func (d *DomainAllow) SetPrivateComment(i string) { + d.PrivateComment = i +} + func (d *DomainAllow) GetPublicComment() string { return d.PublicComment } +func (d *DomainAllow) SetPublicComment(i string) { + d.PublicComment = i +} + func (d *DomainAllow) GetObfuscate() *bool { return d.Obfuscate } +func (d *DomainAllow) SetObfuscate(i *bool) { + d.Obfuscate = i +} + func (d *DomainAllow) GetSubscriptionID() string { return d.SubscriptionID } +func (d *DomainAllow) SetSubscriptionID(i string) { + d.SubscriptionID = i +} + func (d *DomainAllow) GetType() DomainPermissionType { return DomainPermissionAllow } diff --git a/internal/gtsmodel/domainblock.go b/internal/gtsmodel/domainblock.go index 4e0b3ca65..e99fea301 100644 --- a/internal/gtsmodel/domainblock.go +++ b/internal/gtsmodel/domainblock.go @@ -45,6 +45,10 @@ func (d *DomainBlock) GetUpdatedAt() time.Time { return d.UpdatedAt } +func (d *DomainBlock) SetUpdatedAt(i time.Time) { + d.UpdatedAt = i +} + func (d *DomainBlock) GetDomain() string { return d.Domain } @@ -53,26 +57,50 @@ func (d *DomainBlock) GetCreatedByAccountID() string { return d.CreatedByAccountID } +func (d *DomainBlock) SetCreatedByAccountID(i string) { + d.CreatedByAccountID = i +} + func (d *DomainBlock) GetCreatedByAccount() *Account { return d.CreatedByAccount } +func (d *DomainBlock) SetCreatedByAccount(i *Account) { + d.CreatedByAccount = i +} + func (d *DomainBlock) GetPrivateComment() string { return d.PrivateComment } +func (d *DomainBlock) SetPrivateComment(i string) { + d.PrivateComment = i +} + func (d *DomainBlock) GetPublicComment() string { return d.PublicComment } +func (d *DomainBlock) SetPublicComment(i string) { + d.PublicComment = i +} + func (d *DomainBlock) GetObfuscate() *bool { return d.Obfuscate } +func (d *DomainBlock) SetObfuscate(i *bool) { + d.Obfuscate = i +} + func (d *DomainBlock) GetSubscriptionID() string { return d.SubscriptionID } +func (d *DomainBlock) SetSubscriptionID(i string) { + d.SubscriptionID = i +} + func (d *DomainBlock) GetType() DomainPermissionType { return DomainPermissionBlock } diff --git a/internal/gtsmodel/domainpermission.go b/internal/gtsmodel/domainpermission.go index 01e8fdaaa..3d1ee873f 100644 --- a/internal/gtsmodel/domainpermission.go +++ b/internal/gtsmodel/domainpermission.go @@ -19,19 +19,26 @@ package gtsmodel import "time" -// DomainPermission models a domain -// permission entry (block/allow). +// DomainPermission models a domain permission +// entry -- block / allow / draft / exclude. type DomainPermission interface { GetID() string GetCreatedAt() time.Time GetUpdatedAt() time.Time + SetUpdatedAt(i time.Time) GetDomain() string GetCreatedByAccountID() string + SetCreatedByAccountID(i string) GetCreatedByAccount() *Account + SetCreatedByAccount(i *Account) GetPrivateComment() string + SetPrivateComment(i string) GetPublicComment() string + SetPublicComment(i string) GetObfuscate() *bool + SetObfuscate(i *bool) GetSubscriptionID() string + SetSubscriptionID(i string) GetType() DomainPermissionType } diff --git a/internal/gtsmodel/domainpermissiondraft.go b/internal/gtsmodel/domainpermissiondraft.go new file mode 100644 index 000000000..0829dca16 --- /dev/null +++ b/internal/gtsmodel/domainpermissiondraft.go @@ -0,0 +1,106 @@ +// 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" + +type DomainPermissionDraft 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"` // Time when this item was created. + UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // Time when this item was last updated. + PermissionType DomainPermissionType `bun:",notnull,unique:domain_permission_drafts_permission_type_domain_subscription_id_uniq"` // Permission type of the draft. + Domain string `bun:",nullzero,notnull,unique:domain_permission_drafts_permission_type_domain_subscription_id_uniq"` // Domain to block or allow. Eg. 'whatever.com'. + CreatedByAccountID string `bun:"type:CHAR(26),nullzero,notnull"` // Account ID of the creator of this subscription. + CreatedByAccount *Account `bun:"-"` // Account corresponding to createdByAccountID. + PrivateComment string `bun:",nullzero"` // Private comment on this perm, viewable to admins. + PublicComment string `bun:",nullzero"` // Public comment on this perm, viewable (optionally) by everyone. + Obfuscate *bool `bun:",nullzero,notnull,default:false"` // Obfuscate domain name when displaying it publicly. + SubscriptionID string `bun:"type:CHAR(26),unique:domain_permission_drafts_permission_type_domain_subscription_id_uniq"` // ID of the subscription that created this draft, if any. +} + +func (d *DomainPermissionDraft) GetID() string { + return d.ID +} + +func (d *DomainPermissionDraft) GetCreatedAt() time.Time { + return d.CreatedAt +} + +func (d *DomainPermissionDraft) GetUpdatedAt() time.Time { + return d.UpdatedAt +} + +func (d *DomainPermissionDraft) SetUpdatedAt(i time.Time) { + d.UpdatedAt = i +} + +func (d *DomainPermissionDraft) GetDomain() string { + return d.Domain +} + +func (d *DomainPermissionDraft) GetCreatedByAccountID() string { + return d.CreatedByAccountID +} + +func (d *DomainPermissionDraft) SetCreatedByAccountID(i string) { + d.CreatedByAccountID = i +} + +func (d *DomainPermissionDraft) GetCreatedByAccount() *Account { + return d.CreatedByAccount +} + +func (d *DomainPermissionDraft) SetCreatedByAccount(i *Account) { + d.CreatedByAccount = i +} + +func (d *DomainPermissionDraft) GetPrivateComment() string { + return d.PrivateComment +} + +func (d *DomainPermissionDraft) SetPrivateComment(i string) { + d.PrivateComment = i +} + +func (d *DomainPermissionDraft) GetPublicComment() string { + return d.PublicComment +} + +func (d *DomainPermissionDraft) SetPublicComment(i string) { + d.PublicComment = i +} + +func (d *DomainPermissionDraft) GetObfuscate() *bool { + return d.Obfuscate +} + +func (d *DomainPermissionDraft) SetObfuscate(i *bool) { + d.Obfuscate = i +} + +func (d *DomainPermissionDraft) GetSubscriptionID() string { + return d.SubscriptionID +} + +func (d *DomainPermissionDraft) SetSubscriptionID(i string) { + d.SubscriptionID = i +} + +func (d *DomainPermissionDraft) GetType() DomainPermissionType { + return d.PermissionType +} diff --git a/internal/gtsmodel/domainpermissionexclude.go b/internal/gtsmodel/domainpermissionexclude.go new file mode 100644 index 000000000..2a0925ba7 --- /dev/null +++ b/internal/gtsmodel/domainpermissionexclude.go @@ -0,0 +1,92 @@ +// 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" + + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +// DomainPermissionExclude represents one domain that should be excluded +// when domain permission (excludes) are created from subscriptions. +type DomainPermissionExclude 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"` // Time when this item was created. + UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // Time when this item was last updated. + Domain string `bun:",nullzero,notnull,unique"` // Domain to exclude. Eg. 'whatever.com'. + CreatedByAccountID string `bun:"type:CHAR(26),nullzero,notnull"` // Account ID of the creator of this exclude. + CreatedByAccount *Account `bun:"-"` // Account corresponding to createdByAccountID. + PrivateComment string `bun:",nullzero"` // Private comment on this exclude, viewable to admins. +} + +func (d *DomainPermissionExclude) GetID() string { + return d.ID +} + +func (d *DomainPermissionExclude) GetCreatedAt() time.Time { + return d.CreatedAt +} + +func (d *DomainPermissionExclude) GetUpdatedAt() time.Time { + return d.UpdatedAt +} + +func (d *DomainPermissionExclude) SetUpdatedAt(i time.Time) { + d.UpdatedAt = i +} + +func (d *DomainPermissionExclude) GetDomain() string { + return d.Domain +} + +func (d *DomainPermissionExclude) GetCreatedByAccountID() string { + return d.CreatedByAccountID +} + +func (d *DomainPermissionExclude) SetCreatedByAccountID(i string) { + d.CreatedByAccountID = i +} + +func (d *DomainPermissionExclude) GetCreatedByAccount() *Account { + return d.CreatedByAccount +} + +func (d *DomainPermissionExclude) SetCreatedByAccount(i *Account) { + d.CreatedByAccount = i +} + +func (d *DomainPermissionExclude) GetPrivateComment() string { + return d.PrivateComment +} + +func (d *DomainPermissionExclude) SetPrivateComment(i string) { + d.PrivateComment = i +} + +/* + Stubbed functions for interface purposes. +*/ + +func (d *DomainPermissionExclude) GetPublicComment() string { return "" } +func (d *DomainPermissionExclude) SetPublicComment(_ string) {} +func (d *DomainPermissionExclude) GetObfuscate() *bool { return util.Ptr(false) } +func (d *DomainPermissionExclude) SetObfuscate(_ *bool) {} +func (d *DomainPermissionExclude) GetSubscriptionID() string { return "" } +func (d *DomainPermissionExclude) SetSubscriptionID(_ string) {} +func (d *DomainPermissionExclude) GetType() DomainPermissionType { return DomainPermissionUnknown } diff --git a/internal/processing/admin/domainpermission.go b/internal/processing/admin/domainpermission.go index bedaf6a11..55800f458 100644 --- a/internal/processing/admin/domainpermission.go +++ b/internal/processing/admin/domainpermission.go @@ -31,24 +31,6 @@ import ( "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.converter.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. diff --git a/internal/processing/admin/domainpermissiondraft.go b/internal/processing/admin/domainpermissiondraft.go new file mode 100644 index 000000000..0dc17a45a --- /dev/null +++ b/internal/processing/admin/domainpermissiondraft.go @@ -0,0 +1,324 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package admin + +import ( + "context" + "errors" + "fmt" + "net/url" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtscontext" + "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/paging" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +// DomainPermissionDraftGet returns one +// domain permission draft with the given id. +func (p *Processor) DomainPermissionDraftGet( + ctx context.Context, + id string, +) (*apimodel.DomainPermission, gtserror.WithCode) { + permDraft, err := p.state.DB.GetDomainPermissionDraftByID(ctx, id) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting domain permission draft %s: %w", id, err) + return nil, gtserror.NewErrorInternalError(err) + } + + if permDraft == nil { + err := fmt.Errorf("domain permission draft %s not found", id) + return nil, gtserror.NewErrorNotFound(err, err.Error()) + } + + return p.apiDomainPerm(ctx, permDraft, false) +} + +// DomainPermissionDraftsGet returns a page of +// DomainPermissionDrafts with the given parameters. +func (p *Processor) DomainPermissionDraftsGet( + ctx context.Context, + subscriptionID string, + domain string, + permType gtsmodel.DomainPermissionType, + page *paging.Page, +) (*apimodel.PageableResponse, gtserror.WithCode) { + permDrafts, err := p.state.DB.GetDomainPermissionDrafts( + ctx, + permType, + subscriptionID, + domain, + page, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + count := len(permDrafts) + if count == 0 { + return paging.EmptyResponse(), nil + } + + // Get the lowest and highest + // ID values, used for paging. + lo := permDrafts[count-1].ID + hi := permDrafts[0].ID + + // Convert each perm draft to API model. + items := make([]any, len(permDrafts)) + for i, permDraft := range permDrafts { + apiPermDraft, err := p.apiDomainPerm(ctx, permDraft, false) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + items[i] = apiPermDraft + } + + // Assemble next/prev page queries. + query := make(url.Values, 3) + if subscriptionID != "" { + query.Set(apiutil.DomainPermissionSubscriptionIDKey, subscriptionID) + } + if domain != "" { + query.Set(apiutil.DomainPermissionDomainKey, domain) + } + if permType != gtsmodel.DomainPermissionUnknown { + query.Set(apiutil.DomainPermissionPermTypeKey, permType.String()) + } + + return paging.PackageResponse(paging.ResponseParams{ + Items: items, + Path: "/api/v1/admin/domain_permission_drafts", + Next: page.Next(lo, hi), + Prev: page.Prev(lo, hi), + Query: query, + }), nil +} + +func (p *Processor) DomainPermissionDraftCreate( + ctx context.Context, + acct *gtsmodel.Account, + domain string, + permType gtsmodel.DomainPermissionType, + obfuscate bool, + publicComment string, + privateComment string, +) (*apimodel.DomainPermission, gtserror.WithCode) { + permDraft := >smodel.DomainPermissionDraft{ + ID: id.NewULID(), + PermissionType: permType, + Domain: domain, + CreatedByAccountID: acct.ID, + CreatedByAccount: acct, + PrivateComment: privateComment, + PublicComment: publicComment, + Obfuscate: &obfuscate, + } + + if err := p.state.DB.PutDomainPermissionDraft(ctx, permDraft); err != nil { + if errors.Is(err, db.ErrAlreadyExists) { + const text = "a domain permission draft already exists with this permission type, domain, and subscription ID" + err := fmt.Errorf("%w: %s", err, text) + return nil, gtserror.NewErrorConflict(err, text) + } + + // Real error. + err := gtserror.Newf("db error putting domain permission draft: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return p.apiDomainPerm(ctx, permDraft, false) +} + +func (p *Processor) DomainPermissionDraftAccept( + ctx context.Context, + acct *gtsmodel.Account, + id string, + overwrite bool, +) (*apimodel.DomainPermission, string, gtserror.WithCode) { + permDraft, err := p.state.DB.GetDomainPermissionDraftByID(ctx, id) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting domain permission draft %s: %w", id, err) + return nil, "", gtserror.NewErrorInternalError(err) + } + + if permDraft == nil { + err := fmt.Errorf("domain permission draft %s not found", id) + return nil, "", gtserror.NewErrorNotFound(err, err.Error()) + } + + var ( + // Existing permission + // entry, if it exists. + existing gtsmodel.DomainPermission + ) + + // Try to get existing entry. + switch permDraft.PermissionType { + case gtsmodel.DomainPermissionBlock: + existing, err = p.state.DB.GetDomainBlock( + gtscontext.SetBarebones(ctx), + permDraft.Domain, + ) + case gtsmodel.DomainPermissionAllow: + existing, err = p.state.DB.GetDomainAllow( + gtscontext.SetBarebones(ctx), + permDraft.Domain, + ) + } + + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting domain permission %s: %w", id, err) + return nil, "", gtserror.NewErrorInternalError(err) + } + + // Check if we got existing entry. + existed := !util.IsNil(existing) + if existed && !overwrite { + // Domain permission exists and we shouldn't + // overwrite it, leave everything alone. + const text = "a domain permission already exists with this permission type and domain" + return nil, "", gtserror.NewErrorConflict(errors.New(text), text) + } + + // Function to clean up the accepted draft, only called if + // creating or updating permission from draft is successful. + deleteDraft := func() { + if err := p.state.DB.DeleteDomainPermissionDraft(ctx, permDraft.ID); err != nil { + log.Errorf(ctx, "db error deleting domain permission draft: %v", err) + } + } + + if !existed { + // Easy case, we just need to create a new domain + // permission from the draft, and then delete it. + var ( + new *apimodel.DomainPermission + actionID string + errWithCode gtserror.WithCode + ) + + if permDraft.PermissionType == gtsmodel.DomainPermissionBlock { + new, actionID, errWithCode = p.createDomainBlock( + ctx, + acct, + permDraft.Domain, + *permDraft.Obfuscate, + permDraft.PublicComment, + permDraft.PrivateComment, + permDraft.SubscriptionID, + ) + } + + if permDraft.PermissionType == gtsmodel.DomainPermissionAllow { + new, actionID, errWithCode = p.createDomainAllow( + ctx, + acct, + permDraft.Domain, + *permDraft.Obfuscate, + permDraft.PublicComment, + permDraft.PrivateComment, + permDraft.SubscriptionID, + ) + } + + // Clean up the draft + // before returning. + deleteDraft() + + return new, actionID, errWithCode + } + + // Domain permission exists but we should overwrite + // it by just updating the existing domain permission. + // Domain can't change, so no need to re-run side effects. + existing.SetCreatedByAccountID(permDraft.CreatedByAccountID) + existing.SetCreatedByAccount(permDraft.CreatedByAccount) + existing.SetPrivateComment(permDraft.PrivateComment) + existing.SetPublicComment(permDraft.PublicComment) + existing.SetObfuscate(permDraft.Obfuscate) + existing.SetSubscriptionID(permDraft.SubscriptionID) + + switch dp := existing.(type) { + case *gtsmodel.DomainBlock: + err = p.state.DB.UpdateDomainBlock(ctx, dp) + + case *gtsmodel.DomainAllow: + err = p.state.DB.UpdateDomainAllow(ctx, dp) + } + + if err != nil { + err := gtserror.Newf("db error updating existing domain permission: %w", err) + return nil, "", gtserror.NewErrorInternalError(err) + } + + // Clean up the draft + // before returning. + deleteDraft() + + apiPerm, errWithCode := p.apiDomainPerm(ctx, existing, false) + return apiPerm, "", errWithCode +} + +func (p *Processor) DomainPermissionDraftRemove( + ctx context.Context, + acct *gtsmodel.Account, + id string, + excludeTarget bool, +) (*apimodel.DomainPermission, gtserror.WithCode) { + permDraft, err := p.state.DB.GetDomainPermissionDraftByID(ctx, id) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting domain permission draft %s: %w", id, err) + return nil, gtserror.NewErrorInternalError(err) + } + + if permDraft == nil { + err := fmt.Errorf("domain permission draft %s not found", id) + return nil, gtserror.NewErrorNotFound(err, err.Error()) + } + + // Delete the permission draft. + if err := p.state.DB.DeleteDomainPermissionDraft(ctx, permDraft.ID); err != nil { + err := gtserror.Newf("db error deleting domain permission draft: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + if excludeTarget { + // Add a domain permission exclude + // targeting the permDraft's domain. + _, err = p.DomainPermissionExcludeCreate( + ctx, + acct, + permDraft.Domain, + permDraft.PrivateComment, + ) + if err != nil && !errors.Is(err, db.ErrAlreadyExists) { + err := gtserror.Newf("db error creating domain permission exclude: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + } + + return p.apiDomainPerm(ctx, permDraft, false) +} diff --git a/internal/processing/admin/domainpermissionexclude.go b/internal/processing/admin/domainpermissionexclude.go new file mode 100644 index 000000000..761ca8b9c --- /dev/null +++ b/internal/processing/admin/domainpermissionexclude.go @@ -0,0 +1,159 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package admin + +import ( + "context" + "errors" + "fmt" + "net/url" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/paging" +) + +func (p *Processor) DomainPermissionExcludeCreate( + ctx context.Context, + acct *gtsmodel.Account, + domain string, + privateComment string, +) (*apimodel.DomainPermission, gtserror.WithCode) { + permExclude := >smodel.DomainPermissionExclude{ + ID: id.NewULID(), + Domain: domain, + CreatedByAccountID: acct.ID, + CreatedByAccount: acct, + PrivateComment: privateComment, + } + + if err := p.state.DB.PutDomainPermissionExclude(ctx, permExclude); err != nil { + if errors.Is(err, db.ErrAlreadyExists) { + const text = "a domain permission exclude already exists with this permission type and domain" + err := fmt.Errorf("%w: %s", err, text) + return nil, gtserror.NewErrorConflict(err, text) + } + + // Real error. + err := gtserror.Newf("db error putting domain permission exclude: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return p.apiDomainPerm(ctx, permExclude, false) +} + +// DomainPermissionExcludeGet returns one +// domain permission exclude with the given id. +func (p *Processor) DomainPermissionExcludeGet( + ctx context.Context, + id string, +) (*apimodel.DomainPermission, gtserror.WithCode) { + permExclude, err := p.state.DB.GetDomainPermissionExcludeByID(ctx, id) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting domain permission exclude %s: %w", id, err) + return nil, gtserror.NewErrorInternalError(err) + } + + if permExclude == nil { + err := fmt.Errorf("domain permission exclude %s not found", id) + return nil, gtserror.NewErrorNotFound(err, err.Error()) + } + + return p.apiDomainPerm(ctx, permExclude, false) +} + +// DomainPermissionExcludesGet returns a page of +// DomainPermissionExcludes with the given parameters. +func (p *Processor) DomainPermissionExcludesGet( + ctx context.Context, + domain string, + page *paging.Page, +) (*apimodel.PageableResponse, gtserror.WithCode) { + permExcludes, err := p.state.DB.GetDomainPermissionExcludes( + ctx, + domain, + page, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + count := len(permExcludes) + if count == 0 { + return paging.EmptyResponse(), nil + } + + // Get the lowest and highest + // ID values, used for paging. + lo := permExcludes[count-1].ID + hi := permExcludes[0].ID + + // Convert each perm exclude to API model. + items := make([]any, len(permExcludes)) + for i, permExclude := range permExcludes { + apiPermExclude, err := p.apiDomainPerm(ctx, permExclude, false) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + items[i] = apiPermExclude + } + + // Assemble next/prev page queries. + query := make(url.Values, 1) + if domain != "" { + query.Set(apiutil.DomainPermissionDomainKey, domain) + } + + return paging.PackageResponse(paging.ResponseParams{ + Items: items, + Path: "/api/v1/admin/domain_permission_excludes", + Next: page.Next(lo, hi), + Prev: page.Prev(lo, hi), + Query: query, + }), nil +} + +func (p *Processor) DomainPermissionExcludeRemove( + ctx context.Context, + acct *gtsmodel.Account, + id string, +) (*apimodel.DomainPermission, gtserror.WithCode) { + permExclude, err := p.state.DB.GetDomainPermissionExcludeByID(ctx, id) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting domain permission exclude %s: %w", id, err) + return nil, gtserror.NewErrorInternalError(err) + } + + if permExclude == nil { + err := fmt.Errorf("domain permission exclude %s not found", id) + return nil, gtserror.NewErrorNotFound(err, err.Error()) + } + + // Delete the permission exclude. + if err := p.state.DB.DeleteDomainPermissionExclude(ctx, permExclude.ID); err != nil { + err := gtserror.Newf("db error deleting domain permission exclude: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return p.apiDomainPerm(ctx, permExclude, false) +} diff --git a/internal/processing/admin/util.go b/internal/processing/admin/util.go index c82ff2dc1..bc59a2b3b 100644 --- a/internal/processing/admin/util.go +++ b/internal/processing/admin/util.go @@ -22,6 +22,7 @@ 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" @@ -97,3 +98,20 @@ func (p *Processor) rangeDomainAccounts( } } } + +// apiDomainPerm is a cheeky shortcut for returning +// the API version of the given domain permission, +// 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.converter.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 +} diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index b45e0f814..5f919f014 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -1962,7 +1962,8 @@ func (c *Converter) ConversationToAPIConversation( return apiConversation, nil } -// DomainPermToAPIDomainPerm converts a gts model domin block or allow into an api domain permission. +// DomainPermToAPIDomainPerm converts a gtsmodel domain block, +// allow, draft, or ignore into an api domain permission. func (c *Converter) DomainPermToAPIDomainPerm( ctx context.Context, d gtsmodel.DomainPermission, @@ -1995,6 +1996,11 @@ func (c *Converter) DomainPermToAPIDomainPerm( domainPerm.CreatedBy = d.GetCreatedByAccountID() domainPerm.CreatedAt = util.FormatISO8601(d.GetCreatedAt()) + // If this is a draft, also add the permission type. + if _, ok := d.(*gtsmodel.DomainPermissionDraft); ok { + domainPerm.PermissionType = d.GetType().String() + } + return domainPerm, nil } diff --git a/internal/util/ptr.go b/internal/util/ptr.go index 8a89666c4..d0e835c9e 100644 --- a/internal/util/ptr.go +++ b/internal/util/ptr.go @@ -17,6 +17,8 @@ package util +import "unsafe" + // EqualPtrs returns whether the values contained within two comparable ptr types are equal. func EqualPtrs[T comparable](t1, t2 *T) bool { switch { @@ -59,3 +61,8 @@ func PtrOrValue[T any](t *T, value T) T { } return value } + +func IsNil(i interface{}) bool { + type eface struct{ _, data unsafe.Pointer } + return (*eface)(unsafe.Pointer(&i)).data == nil +} |