diff options
author | 2025-01-05 13:20:33 +0100 | |
---|---|---|
committer | 2025-01-05 13:20:33 +0100 | |
commit | e9bb7ddd3aa11da5c48a75c4a600f8fe5cc1c990 (patch) | |
tree | a2897775112a821aa093b6e2686044814912001f /internal | |
parent | [chore] Update robots.txt with more AI bots (#3634) (diff) | |
download | gotosocial-e9bb7ddd3aa11da5c48a75c4a600f8fe5cc1c990.tar.xz |
[feature] Create/update/remove domain permission subscriptions (#3623)
* [feature] Create/update/remove domain permission subscriptions
* lint
* envparsing
* remove errant fmt.Println
* create drafts, subs, exclude, from snapshot models
* name etag column correctly
* remove count column
* lint
Diffstat (limited to 'internal')
30 files changed, 2550 insertions, 165 deletions
diff --git a/internal/api/client/admin/admin.go b/internal/api/client/admin/admin.go index a33a6448a..68a088b4d 100644 --- a/internal/api/client/admin/admin.go +++ b/internal/api/client/admin/admin.go @@ -28,43 +28,47 @@ 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 - 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" + 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 + DomainPermissionSubscriptionsPath = BasePath + "/domain_permission_subscriptions" + DomainPermissionSubscriptionsPathWithID = DomainPermissionSubscriptionsPath + "/:" + apiutil.IDKey + DomainPermissionSubscriptionsPreviewPath = DomainPermissionSubscriptionsPath + "/preview" + DomainPermissionSubscriptionRemovePath = DomainPermissionSubscriptionsPathWithID + "/remove" + 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" @@ -118,6 +122,14 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H attachHandler(http.MethodGet, DomainPermissionExcludesPathWithID, m.DomainPermissionExcludeGETHandler) attachHandler(http.MethodDelete, DomainPermissionExcludesPathWithID, m.DomainPermissionExcludeDELETEHandler) + // domain permission subscriptions stuff + attachHandler(http.MethodPost, DomainPermissionSubscriptionsPath, m.DomainPermissionSubscriptionPOSTHandler) + attachHandler(http.MethodGet, DomainPermissionSubscriptionsPath, m.DomainPermissionSubscriptionsGETHandler) + attachHandler(http.MethodGet, DomainPermissionSubscriptionsPreviewPath, m.DomainPermissionSubscriptionsPreviewGETHandler) + attachHandler(http.MethodGet, DomainPermissionSubscriptionsPathWithID, m.DomainPermissionSubscriptionGETHandler) + attachHandler(http.MethodPatch, DomainPermissionSubscriptionsPathWithID, m.DomainPermissionSubscriptionPATCHHandler) + attachHandler(http.MethodPost, DomainPermissionSubscriptionRemovePath, m.DomainPermissionSubscriptionRemovePOSTHandler) + // header filtering administration routes attachHandler(http.MethodGet, HeaderAllowsPathWithID, m.HeaderFilterAllowGET) attachHandler(http.MethodGet, HeaderBlocksPathWithID, m.HeaderFilterBlockGET) diff --git a/internal/api/client/admin/domainpermission.go b/internal/api/client/admin/domainpermission.go index 90c0eb4c0..5138be898 100644 --- a/internal/api/client/admin/domainpermission.go +++ b/internal/api/client/admin/domainpermission.go @@ -302,3 +302,45 @@ func (m *Module) getDomainPermissions( apiutil.JSON(c, http.StatusOK, domainPerm) } + +// parseDomainPermissionType is a util function to parse i +// to a DomainPermissionType, or return a suitable error. +func parseDomainPermissionType(i string) ( + permType gtsmodel.DomainPermissionType, + errWithCode gtserror.WithCode, +) { + if i == "" { + const errText = "permission_type not set, must be one of block or allow" + errWithCode = gtserror.NewErrorBadRequest(errors.New(errText), errText) + return + } + + permType = gtsmodel.ParseDomainPermissionType(i) + if permType == gtsmodel.DomainPermissionUnknown { + var errText = fmt.Sprintf("permission_type %s not recognized, must be one of block or allow", i) + errWithCode = gtserror.NewErrorBadRequest(errors.New(errText), errText) + } + + return +} + +// parseDomainPermSubContentType is a util function to parse i +// to a DomainPermSubContentType, or return a suitable error. +func parseDomainPermSubContentType(i string) ( + contentType gtsmodel.DomainPermSubContentType, + errWithCode gtserror.WithCode, +) { + if i == "" { + const errText = "content_type not set, must be one of text/csv, text/plain or application/json" + errWithCode = gtserror.NewErrorBadRequest(errors.New(errText), errText) + return + } + + contentType = gtsmodel.NewDomainPermSubContentType(i) + if contentType == gtsmodel.DomainPermSubContentTypeUnknown { + var errText = fmt.Sprintf("content_type %s not recognized, must be one of text/csv, text/plain or application/json", i) + errWithCode = gtserror.NewErrorBadRequest(errors.New(errText), errText) + } + + return +} diff --git a/internal/api/client/admin/domainpermissiondraftcreate.go b/internal/api/client/admin/domainpermissiondraftcreate.go index d20842ebf..ec94f947b 100644 --- a/internal/api/client/admin/domainpermissiondraftcreate.go +++ b/internal/api/client/admin/domainpermissiondraftcreate.go @@ -26,7 +26,6 @@ import ( 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" ) @@ -136,24 +135,8 @@ func (m *Module) DomainPermissionDraftsPOSTHandler(c *gin.Context) { 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) + permType, errWithCode := parseDomainPermissionType(form.PermissionType) + if errWithCode != nil { apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return } diff --git a/internal/api/client/admin/domainpermissiondraftsget.go b/internal/api/client/admin/domainpermissiondraftsget.go index d63179afc..21ce5dc43 100644 --- a/internal/api/client/admin/domainpermissiondraftsget.go +++ b/internal/api/client/admin/domainpermissiondraftsget.go @@ -149,7 +149,7 @@ func (m *Module) DomainPermissionDraftsGETHandler(c *gin.Context) { permTypeStr := c.Query(apiutil.DomainPermissionPermTypeKey) permType := gtsmodel.ParseDomainPermissionType(permTypeStr) - if permType == gtsmodel.DomainPermissionUnknown { + if permTypeStr != "" && permType == gtsmodel.DomainPermissionUnknown { text := fmt.Sprintf( "permission_type %s not recognized, valid values are empty string, block, or allow", permTypeStr, diff --git a/internal/api/client/admin/domainpermissionsubscriptioncreate.go b/internal/api/client/admin/domainpermissionsubscriptioncreate.go new file mode 100644 index 000000000..dd0b43aca --- /dev/null +++ b/internal/api/client/admin/domainpermissionsubscriptioncreate.go @@ -0,0 +1,244 @@ +// 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" + "net/url" + + "github.com/gin-gonic/gin" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +// DomainPermissionSubscriptionPOSTHandler swagger:operation POST /api/v1/admin/domain_permission_subscriptions domainPermissionSubscriptionCreate +// +// Create a domain permission subscription with the given parameters. +// +// --- +// tags: +// - admin +// +// consumes: +// - multipart/form-data +// - application/json +// +// produces: +// - application/json +// +// parameters: +// - +// name: priority +// in: formData +// description: >- +// Priority of this subscription compared to others of the same permission type. +// 0-255 (higher = higher priority). Higher priority subscriptions will overwrite +// permissions generated by lower priority subscriptions. When two subscriptions +// have the same `priority` value, priority is indeterminate, so it's recommended +// to always set this value manually. +// type: number +// minimum: 0 +// maximum: 255 +// default: 0 +// - +// name: title +// in: formData +// description: Optional title for this subscription. +// type: string +// - +// name: permission_type +// required: true +// in: formData +// description: >- +// Type of permissions to create by parsing the targeted file/list. +// One of "allow" or "block". +// type: string +// - +// name: as_draft +// in: formData +// description: >- +// If true, domain permissions arising from this subscription will be +// created as drafts that must be approved by a moderator to take effect. +// If false, domain permissions from this subscription will come into force immediately. +// Defaults to "true". +// type: boolean +// default: true +// - +// name: adopt_orphans +// in: formData +// description: >- +// If true, this domain permission subscription will "adopt" domain permissions +// which already exist on the instance, and which meet the following conditions: +// 1) they have no subscription ID (ie., they're "orphaned") and 2) they are present +// in the subscribed list. Such orphaned domain permissions will be given this +// subscription's subscription ID value and be managed by this subscription. +// type: boolean +// default: false +// - +// name: uri +// required: true +// in: formData +// description: URI to call in order to fetch the permissions list. +// type: string +// - +// name: content_type +// required: true +// in: formData +// description: >- +// MIME content type to use when parsing the permissions list. +// One of "text/plain", "text/csv", and "application/json". +// type: string +// - +// name: fetch_username +// in: formData +// description: >- +// Optional basic auth username to provide when fetching given uri. +// If set, will be transmitted along with `fetch_password` when doing the fetch. +// type: string +// - +// name: fetch_password +// in: formData +// description: >- +// Optional basic auth password to provide when fetching given uri. +// If set, will be transmitted along with `fetch_username` when doing the fetch. +// type: string +// +// security: +// - OAuth2 Bearer: +// - admin +// +// responses: +// '200': +// description: The newly created domain permission subscription. +// schema: +// "$ref": "#/definitions/domainPermissionSubscription" +// '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) DomainPermissionSubscriptionPOSTHandler(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.DomainPermissionSubscriptionRequest) + if err := c.ShouldBind(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + return + } + + // Check priority. + // Default to 0. + priority := util.PtrOrZero(form.Priority) + if priority < 0 || priority > 255 { + const errText = "priority must be a number in the range 0 to 255" + errWithCode := gtserror.NewErrorBadRequest(errors.New(errText), errText) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + // Ensure URI is set. + if form.URI == nil { + const errText = "uri must be set" + errWithCode := gtserror.NewErrorBadRequest(errors.New(errText), errText) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + // Ensure URI is parseable. + uri, err := url.Parse(*form.URI) + if err != nil { + err := fmt.Errorf("invalid uri provided: %w", err) + errWithCode := gtserror.NewErrorBadRequest(err, err.Error()) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + // Normalize URI by converting back to string. + uriStr := uri.String() + + // Content type must be set. + contentTypeStr := util.PtrOrZero(form.ContentType) + contentType, errWithCode := parseDomainPermSubContentType(contentTypeStr) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + // Permission type must be set. + permTypeStr := util.PtrOrZero(form.PermissionType) + permType, errWithCode := parseDomainPermissionType(permTypeStr) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + // Default `as_draft` to true. + asDraft := util.PtrOrValue(form.AsDraft, true) + + permSub, errWithCode := m.processor.Admin().DomainPermissionSubscriptionCreate( + c.Request.Context(), + authed.Account, + uint8(priority), // #nosec G115 -- Validated above. + util.PtrOrZero(form.Title), // Optional. + uriStr, + contentType, + permType, + asDraft, + util.PtrOrZero(form.FetchUsername), // Optional. + util.PtrOrZero(form.FetchPassword), // Optional. + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, permSub) +} diff --git a/internal/api/client/admin/domainpermissionsubscriptionget.go b/internal/api/client/admin/domainpermissionsubscriptionget.go new file mode 100644 index 000000000..841e37f24 --- /dev/null +++ b/internal/api/client/admin/domainpermissionsubscriptionget.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" +) + +// DomainPermissionSubscriptionGETHandler swagger:operation GET /api/v1/admin/domain_permission_subscriptions/{id} domainPermissionSubscriptionGet +// +// Get domain permission subscription with the given ID. +// +// --- +// tags: +// - admin +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// required: true +// in: path +// description: ID of the domain permission subscription. +// type: string +// +// security: +// - OAuth2 Bearer: +// - admin +// +// responses: +// '200': +// description: Domain permission subscription. +// schema: +// "$ref": "#/definitions/domainPermissionSubscription" +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) DomainPermissionSubscriptionGETHandler(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 + } + + permSub, errWithCode := m.processor.Admin().DomainPermissionSubscriptionGet(c.Request.Context(), id) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, permSub) +} diff --git a/internal/api/client/admin/domainpermissionsubscriptionremove.go b/internal/api/client/admin/domainpermissionsubscriptionremove.go new file mode 100644 index 000000000..97f226a31 --- /dev/null +++ b/internal/api/client/admin/domainpermissionsubscriptionremove.go @@ -0,0 +1,143 @@ +// 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/util" +) + +// DomainPermissionSubscriptionRemovePOSTHandler swagger:operation POST /api/v1/admin/domain_permission_subscriptions/{id}/remove domainPermissionSubscriptionRemove +// +// Remove a domain permission subscription. +// +// --- +// tags: +// - admin +// +// consumes: +// - multipart/form-data +// - application/json +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// required: true +// in: path +// description: ID of the domain permission subscription. +// type: string +// - +// name: remove_children +// in: formData +// description: >- +// When removing the domain permission subscription, also +// remove children of this subscription, ie., domain permissions +// that are managed by this subscription. If false, then children +// will instead be orphaned but not removed. +// +// Note that removed permissions may end up being created again later +// by another domain permission subscription of lower priority than +// the removed subscription. Likewise, orphaned children may be later +// adopted by another subscription. +// type: boolean +// default: true +// +// security: +// - OAuth2 Bearer: +// - admin +// +// responses: +// '200': +// description: The removed domain permission subscription. +// schema: +// "$ref": "#/definitions/domainPermissionSubscription" +// '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) DomainPermissionSubscriptionRemovePOSTHandler(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 { + RemoveChildren *bool `json:"remove_children" form:"remove_children"` + } + + form := new(RemoveForm) + if err := c.ShouldBind(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + return + } + + // Default removeChildren to true. + removeChildren := util.PtrOrValue(form.RemoveChildren, true) + + permSub, errWithCode := m.processor.Admin().DomainPermissionSubscriptionRemove( + c.Request.Context(), + id, + removeChildren, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, permSub) +} diff --git a/internal/api/client/admin/domainpermissionsubscriptionsget.go b/internal/api/client/admin/domainpermissionsubscriptionsget.go new file mode 100644 index 000000000..477013ec9 --- /dev/null +++ b/internal/api/client/admin/domainpermissionsubscriptionsget.go @@ -0,0 +1,177 @@ +// 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" +) + +// DomainPermissionSubscriptionsGETHandler swagger:operation GET /api/v1/admin/domain_permission_subscriptions domainPermissionSubscriptionsGet +// +// View domain permission subscriptions. +// +// The subscriptions 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_subscriptions?limit=20&max_id=01FC0SKA48HNSVR6YKZCQGS2V8>; rel="next", <https://example.org/api/v1/admin/domain_permission_subscriptions?limit=20&min_id=01FC0SKW5JK2Q4EVAV2B462YY0>; rel="prev" +// ```` +// +// --- +// tags: +// - admin +// +// produces: +// - application/json +// +// parameters: +// - +// name: permission_type +// type: string +// description: Filter on "block" or "allow" type subscriptions. +// 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 subscriptions. +// schema: +// type: array +// items: +// "$ref": "#/definitions/domainPermissionSubscription" +// 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) DomainPermissionSubscriptionsGETHandler(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().DomainPermissionSubscriptionsGet( + c.Request.Context(), + gtsmodel.ParseDomainPermissionType(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/domainpermissionsubscriptionspreviewget.go b/internal/api/client/admin/domainpermissionsubscriptionspreviewget.go new file mode 100644 index 000000000..dc46c159b --- /dev/null +++ b/internal/api/client/admin/domainpermissionsubscriptionspreviewget.go @@ -0,0 +1,132 @@ +// 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" +) + +// DomainPermissionSubscriptionsPreviewGETHandler swagger:operation GET /api/v1/admin/domain_permission_subscriptions/preview domainPermissionSubscriptionsPreviewGet +// +// View all domain permission subscriptions of the given permission type, in priority order (highest to lowest). +// +// This view allows you to see the order in which domain permissions will actually be fetched and created. +// +// --- +// tags: +// - admin +// +// produces: +// - application/json +// +// parameters: +// - +// name: permission_type +// type: string +// description: Filter on "block" or "allow" type subscriptions. +// in: query +// required: true +// +// security: +// - OAuth2 Bearer: +// - admin +// +// responses: +// '200': +// description: Domain permission subscriptions. +// schema: +// type: array +// items: +// "$ref": "#/definitions/domainPermissionSubscription" +// '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) DomainPermissionSubscriptionsPreviewGETHandler(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. + + case "": + // Not set. + const text = "permission_type must be set, valid values are block or allow" + errWithCode := gtserror.NewErrorBadRequest(errors.New(text), text) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + + default: + // Invalid. + text := fmt.Sprintf( + "permission_type %s not recognized, valid values are block or allow", + permType, + ) + errWithCode := gtserror.NewErrorBadRequest(errors.New(text), text) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + resp, errWithCode := m.processor.Admin().DomainPermissionSubscriptionsGetByPriority( + c.Request.Context(), + gtsmodel.ParseDomainPermissionType(permType), + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, resp) +} diff --git a/internal/api/client/admin/domainpermissionsubscriptionupdate.go b/internal/api/client/admin/domainpermissionsubscriptionupdate.go new file mode 100644 index 000000000..de73c4d3e --- /dev/null +++ b/internal/api/client/admin/domainpermissionsubscriptionupdate.go @@ -0,0 +1,254 @@ +// 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" + "net/url" + + "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" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +// DomainPermissionSubscriptionPATCHHandler swagger:operation PATCH /api/v1/admin/domain_permission_subscriptions/${id} domainPermissionSubscriptionUpdate +// +// Update a domain permission subscription with the given parameters. +// +// --- +// tags: +// - admin +// +// consumes: +// - multipart/form-data +// - application/json +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// required: true +// in: path +// description: ID of the domain permission subscription. +// type: string +// - +// name: priority +// in: formData +// description: >- +// Priority of this subscription compared to others of the same permission type. +// 0-255 (higher = higher priority). Higher priority subscriptions will overwrite +// permissions generated by lower priority subscriptions. When two subscriptions +// have the same `priority` value, priority is indeterminate, so it's recommended +// to always set this value manually. +// type: number +// minimum: 0 +// maximum: 255 +// - +// name: title +// in: formData +// description: Optional title for this subscription. +// type: string +// - +// name: uri +// in: formData +// description: URI to call in order to fetch the permissions list. +// type: string +// - +// name: as_draft +// in: formData +// description: >- +// If true, domain permissions arising from this subscription will be +// created as drafts that must be approved by a moderator to take effect. +// If false, domain permissions from this subscription will come into force immediately. +// Defaults to "true". +// type: boolean +// default: true +// - +// name: adopt_orphans +// in: formData +// description: >- +// If true, this domain permission subscription will "adopt" domain permissions +// which already exist on the instance, and which meet the following conditions: +// 1) they have no subscription ID (ie., they're "orphaned") and 2) they are present +// in the subscribed list. Such orphaned domain permissions will be given this +// subscription's subscription ID value and be managed by this subscription. +// type: boolean +// default: false +// - +// name: content_type +// in: formData +// description: >- +// MIME content type to use when parsing the permissions list. +// One of "text/plain", "text/csv", and "application/json". +// type: string +// - +// name: fetch_username +// in: formData +// description: >- +// Optional basic auth username to provide when fetching given uri. +// If set, will be transmitted along with `fetch_password` when doing the fetch. +// type: string +// - +// name: fetch_password +// in: formData +// description: >- +// Optional basic auth password to provide when fetching given uri. +// If set, will be transmitted along with `fetch_username` when doing the fetch. +// type: string +// +// security: +// - OAuth2 Bearer: +// - admin +// +// responses: +// '200': +// description: The updated domain permission subscription. +// schema: +// "$ref": "#/definitions/domainPermissionSubscription" +// '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) DomainPermissionSubscriptionPATCHHandler(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 + } + + // Parse + validate form. + form := new(apimodel.DomainPermissionSubscriptionRequest) + if err := c.ShouldBind(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + return + } + + // Normalize priority if set. + var priority *uint8 + if form.Priority != nil { + prioInt := *form.Priority + if prioInt < 0 || prioInt > 255 { + const errText = "priority must be a number in the range 0 to 255" + errWithCode := gtserror.NewErrorBadRequest(errors.New(errText), errText) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + priority = util.Ptr(uint8(prioInt)) // #nosec G115 -- Just validated. + } + + // Validate URI if set. + var uriStr *string + if form.URI != nil { + uri, err := url.Parse(*form.URI) + if err != nil { + err := fmt.Errorf("invalid uri provided: %w", err) + errWithCode := gtserror.NewErrorBadRequest(err, err.Error()) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + // Normalize URI by converting back to string. + uriStr = util.Ptr(uri.String()) + } + + // Validate content type if set. + var contentType *gtsmodel.DomainPermSubContentType + if form.ContentType != nil { + ct, errWithCode := parseDomainPermSubContentType(*form.ContentType) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + contentType = &ct + } + + // Make sure at least one field is set, + // otherwise we're trying to update nothing. + if priority == nil && + form.Title == nil && + uriStr == nil && + contentType == nil && + form.AsDraft == nil && + form.AdoptOrphans == nil && + form.FetchUsername == nil && + form.FetchPassword == nil { + const errText = "no updateable fields set on request" + errWithCode := gtserror.NewErrorBadRequest(errors.New(errText), errText) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + permSub, errWithCode := m.processor.Admin().DomainPermissionSubscriptionUpdate( + c.Request.Context(), + id, + priority, + form.Title, + uriStr, + contentType, + form.AsDraft, + form.AdoptOrphans, + form.FetchUsername, + form.FetchPassword, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, permSub) +} diff --git a/internal/api/model/domain.go b/internal/api/model/domain.go index c973c7d92..94a190f63 100644 --- a/internal/api/model/domain.go +++ b/internal/api/model/domain.go @@ -99,3 +99,101 @@ type DomainKeysExpireRequest struct { // hostname/domain to expire keys for. Domain string `form:"domain" json:"domain"` } + +// DomainPermissionSubscription represents an auto-refreshing subscription to a list of domain permissions (allows, blocks). +// +// swagger:model domainPermissionSubscription +type DomainPermissionSubscription struct { + // The ID of the domain permission subscription. + // example: 01FBW21XJA09XYX51KV5JVBW0F + // readonly: true + ID string `json:"id"` + // Priority of this subscription compared to others of the same permission type. 0-255 (higher = higher priority). + // example: 100 + Priority uint8 `json:"priority"` + // Title of this subscription, as set by admin who created or updated it. + // example: really cool list of neato pals + Title string `json:"title"` + // The type of domain permission subscription (allow, block). + // example: block + PermissionType string `json:"permission_type"` + // If true, domain permissions arising from this subscription will be created as drafts that must be approved by a moderator to take effect. If false, domain permissions from this subscription will come into force immediately. + // example: true + AsDraft bool `json:"as_draft"` + // If true, this domain permission subscription will "adopt" domain permissions which already exist on the instance, and which meet the following conditions: 1) they have no subscription ID (ie., they're "orphaned") and 2) they are present in the subscribed list. Such orphaned domain permissions will be given this subscription's subscription ID value. + // example: false + AdoptOrphans bool `json:"adopt_orphans"` + // Time at which the subscription was created (ISO 8601 Datetime). + // example: 2021-07-30T09:20:25+00:00 + CreatedAt string `json:"created_at"` + // ID of the account that created this subscription. + // example: 01FBW21XJA09XYX51KV5JVBW0F + // readonly: true + CreatedBy string `json:"created_by"` + // URI to call in order to fetch the permissions list. + // example: https://www.example.org/blocklists/list1.csv + URI string `json:"uri"` + // MIME content type to use when parsing the permissions list. + // example: text/csv + ContentType string `json:"content_type"` + // (Optional) username to set for basic auth when doing a fetch of URI. + // example: admin123 + FetchUsername string `json:"fetch_username,omitempty"` + // (Optional) password to set for basic auth when doing a fetch of URI. + // example: admin123 + FetchPassword string `json:"fetch_password,omitempty"` + // Time of the most recent fetch attempt (successful or otherwise) (ISO 8601 Datetime). + // example: 2021-07-30T09:20:25+00:00 + // readonly: true + FetchedAt string `json:"fetched_at,omitempty"` + // Time of the most recent successful fetch (ISO 8601 Datetime). + // example: 2021-07-30T09:20:25+00:00 + // readonly: true + SuccessfullyFetchedAt string `json:"successfully_fetched_at,omitempty"` + // If most recent fetch attempt failed, this field will contain an error message related to the fetch attempt. + // example: Oopsie doopsie, we made a fucky wucky. + // readonly: true + Error string `json:"error,omitempty"` + // Count of domain permission entries discovered at URI on last (successful) fetch. + // example: 53 + // readonly: true + Count uint64 `json:"count"` +} + +// DomainPermissionSubscriptionRequest represents a request to create or update a domain permission subscription.. +// +// swagger:ignore +type DomainPermissionSubscriptionRequest struct { + // Priority of this subscription compared to others of the same permission type. 0-255 (higher = higher priority). + // example: 100 + Priority *int `form:"priority" json:"priority"` + // Title of this subscription, as set by admin who created or updated it. + // example: really cool list of neato pals + Title *string `form:"title" json:"title"` + // The type of domain permission subscription (allow, block). + // example: block + PermissionType *string `form:"permission_type" json:"permission_type"` + // URI to call in order to fetch the permissions list. + // example: https://www.example.org/blocklists/list1.csv + URI *string `form:"uri" json:"uri"` + // MIME content type to use when parsing the permissions list. + // example: text/csv + ContentType *string `form:"content_type" json:"content_type"` + // If true, domain permissions arising from this subscription will be + // created as drafts that must be approved by a moderator to take effect. + // If false, domain permissions from this subscription will come into force immediately. + // example: true + AsDraft *bool `form:"as_draft" json:"as_draft"` + // If true, this domain permission subscription will "adopt" domain permissions + // which already exist on the instance, and which meet the following conditions: + // 1) they have no subscription ID (ie., they're "orphaned") and 2) they are present + // in the subscribed list. Such orphaned domain permissions will be given this + // subscription's subscription ID value and be managed by this subscription. + AdoptOrphans *bool `form:"adopt_orphans" json:"adopt_orphans"` + // (Optional) username to set for basic auth when doing a fetch of URI. + // example: admin123 + FetchUsername *string `form:"fetch_username" json:"fetch_username"` + // (Optional) password to set for basic auth when doing a fetch of URI. + // example: admin123 + FetchPassword *string `form:"fetch_password" json:"fetch_password"` +} diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 1a66fcd6b..560fbc9f6 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -75,6 +75,7 @@ func (c *Caches) Init() { c.initDomainAllow() c.initDomainBlock() c.initDomainPermissionDraft() + c.initDomainPermissionSubscription() c.initDomainPermissionExclude() c.initEmoji() c.initEmojiCategory() diff --git a/internal/cache/db.go b/internal/cache/db.go index 7a47b811f..1052446c4 100644 --- a/internal/cache/db.go +++ b/internal/cache/db.go @@ -70,6 +70,9 @@ type DBCaches struct { // DomainPermissionDraft provides access to the domain permission draft database cache. DomainPermissionDraft StructCache[*gtsmodel.DomainPermissionDraft] + // DomainPermissionSubscription provides access to the domain permission subscription database cache. + DomainPermissionSubscription StructCache[*gtsmodel.DomainPermissionSubscription] + // DomainPermissionExclude provides access to the domain permission exclude database cache. DomainPermissionExclude *domain.Cache @@ -589,6 +592,37 @@ func (c *Caches) initDomainPermissionDraft() { }) } +func (c *Caches) initDomainPermissionSubscription() { + // Calculate maximum cache size. + cap := calculateResultCacheMax( + sizeofDomainPermissionSubscription(), // model in-mem size. + config.GetCacheDomainPermissionSubscriptionMemRation(), + ) + + log.Infof(nil, "cache size = %d", cap) + + copyF := func(d1 *gtsmodel.DomainPermissionSubscription) *gtsmodel.DomainPermissionSubscription { + d2 := new(gtsmodel.DomainPermissionSubscription) + *d2 = *d1 + + // Don't include ptr fields that + // will be populated separately. + d2.CreatedByAccount = nil + + return d2 + } + + c.DB.DomainPermissionSubscription.Init(structr.CacheConfig[*gtsmodel.DomainPermissionSubscription]{ + Indices: []structr.IndexConfig{ + {Fields: "ID"}, + {Fields: "URI"}, + }, + MaxSize: cap, + IgnoreErr: ignoreErrors, + Copy: copyF, + }) +} + func (c *Caches) initDomainPermissionExclude() { c.DB.DomainPermissionExclude = new(domain.Cache) } diff --git a/internal/cache/size.go b/internal/cache/size.go index 988755099..2c8772f96 100644 --- a/internal/cache/size.go +++ b/internal/cache/size.go @@ -357,6 +357,25 @@ func sizeofDomainPermissionDraft() uintptr { })) } +func sizeofDomainPermissionSubscription() uintptr { + return uintptr(size.Of(>smodel.DomainPermissionSubscription{ + ID: exampleID, + Priority: uint8(255), + Title: exampleTextSmall, + PermissionType: gtsmodel.DomainPermissionBlock, + AsDraft: util.Ptr(true), + CreatedByAccountID: exampleID, + URI: exampleURI, + ContentType: gtsmodel.DomainPermSubContentTypeCSV, + FetchUsername: "username", + FetchPassword: "password", + FetchedAt: exampleTime, + SuccessfullyFetchedAt: exampleTime, + ETag: exampleID, + Error: exampleTextSmall, + })) +} + func sizeofEmoji() uintptr { return uintptr(size.Of(>smodel.Emoji{ ID: exampleID, diff --git a/internal/config/config.go b/internal/config/config.go index 413743409..2bf2a77ad 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -196,60 +196,61 @@ type HTTPClientConfiguration struct { } type CacheConfiguration struct { - MemoryTarget bytesize.Size `name:"memory-target"` - AccountMemRatio float64 `name:"account-mem-ratio"` - AccountNoteMemRatio float64 `name:"account-note-mem-ratio"` - AccountSettingsMemRatio float64 `name:"account-settings-mem-ratio"` - AccountStatsMemRatio float64 `name:"account-stats-mem-ratio"` - ApplicationMemRatio float64 `name:"application-mem-ratio"` - BlockMemRatio float64 `name:"block-mem-ratio"` - BlockIDsMemRatio float64 `name:"block-ids-mem-ratio"` - BoostOfIDsMemRatio float64 `name:"boost-of-ids-mem-ratio"` - 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"` - FilterKeywordMemRatio float64 `name:"filter-keyword-mem-ratio"` - FilterStatusMemRatio float64 `name:"filter-status-mem-ratio"` - FollowMemRatio float64 `name:"follow-mem-ratio"` - FollowIDsMemRatio float64 `name:"follow-ids-mem-ratio"` - FollowRequestMemRatio float64 `name:"follow-request-mem-ratio"` - FollowRequestIDsMemRatio float64 `name:"follow-request-ids-mem-ratio"` - FollowingTagIDsMemRatio float64 `name:"following-tag-ids-mem-ratio"` - InReplyToIDsMemRatio float64 `name:"in-reply-to-ids-mem-ratio"` - InstanceMemRatio float64 `name:"instance-mem-ratio"` - InteractionRequestMemRatio float64 `name:"interaction-request-mem-ratio"` - ListMemRatio float64 `name:"list-mem-ratio"` - ListIDsMemRatio float64 `name:"list-ids-mem-ratio"` - ListedIDsMemRatio float64 `name:"listed-ids-mem-ratio"` - MarkerMemRatio float64 `name:"marker-mem-ratio"` - MediaMemRatio float64 `name:"media-mem-ratio"` - MentionMemRatio float64 `name:"mention-mem-ratio"` - MoveMemRatio float64 `name:"move-mem-ratio"` - NotificationMemRatio float64 `name:"notification-mem-ratio"` - PollMemRatio float64 `name:"poll-mem-ratio"` - PollVoteMemRatio float64 `name:"poll-vote-mem-ratio"` - PollVoteIDsMemRatio float64 `name:"poll-vote-ids-mem-ratio"` - ReportMemRatio float64 `name:"report-mem-ratio"` - SinBinStatusMemRatio float64 `name:"sin-bin-status-mem-ratio"` - StatusMemRatio float64 `name:"status-mem-ratio"` - StatusBookmarkMemRatio float64 `name:"status-bookmark-mem-ratio"` - StatusBookmarkIDsMemRatio float64 `name:"status-bookmark-ids-mem-ratio"` - StatusEditMemRatio float64 `name:"status-edit-mem-ratio"` - StatusFaveMemRatio float64 `name:"status-fave-mem-ratio"` - StatusFaveIDsMemRatio float64 `name:"status-fave-ids-mem-ratio"` - TagMemRatio float64 `name:"tag-mem-ratio"` - ThreadMuteMemRatio float64 `name:"thread-mute-mem-ratio"` - TokenMemRatio float64 `name:"token-mem-ratio"` - TombstoneMemRatio float64 `name:"tombstone-mem-ratio"` - UserMemRatio float64 `name:"user-mem-ratio"` - UserMuteMemRatio float64 `name:"user-mute-mem-ratio"` - UserMuteIDsMemRatio float64 `name:"user-mute-ids-mem-ratio"` - WebfingerMemRatio float64 `name:"webfinger-mem-ratio"` - VisibilityMemRatio float64 `name:"visibility-mem-ratio"` + MemoryTarget bytesize.Size `name:"memory-target"` + AccountMemRatio float64 `name:"account-mem-ratio"` + AccountNoteMemRatio float64 `name:"account-note-mem-ratio"` + AccountSettingsMemRatio float64 `name:"account-settings-mem-ratio"` + AccountStatsMemRatio float64 `name:"account-stats-mem-ratio"` + ApplicationMemRatio float64 `name:"application-mem-ratio"` + BlockMemRatio float64 `name:"block-mem-ratio"` + BlockIDsMemRatio float64 `name:"block-ids-mem-ratio"` + BoostOfIDsMemRatio float64 `name:"boost-of-ids-mem-ratio"` + 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"` + DomainPermissionSubscriptionMemRation float64 `name:"domain-permission-subscription-mem-ratio"` + EmojiMemRatio float64 `name:"emoji-mem-ratio"` + EmojiCategoryMemRatio float64 `name:"emoji-category-mem-ratio"` + FilterMemRatio float64 `name:"filter-mem-ratio"` + FilterKeywordMemRatio float64 `name:"filter-keyword-mem-ratio"` + FilterStatusMemRatio float64 `name:"filter-status-mem-ratio"` + FollowMemRatio float64 `name:"follow-mem-ratio"` + FollowIDsMemRatio float64 `name:"follow-ids-mem-ratio"` + FollowRequestMemRatio float64 `name:"follow-request-mem-ratio"` + FollowRequestIDsMemRatio float64 `name:"follow-request-ids-mem-ratio"` + FollowingTagIDsMemRatio float64 `name:"following-tag-ids-mem-ratio"` + InReplyToIDsMemRatio float64 `name:"in-reply-to-ids-mem-ratio"` + InstanceMemRatio float64 `name:"instance-mem-ratio"` + InteractionRequestMemRatio float64 `name:"interaction-request-mem-ratio"` + ListMemRatio float64 `name:"list-mem-ratio"` + ListIDsMemRatio float64 `name:"list-ids-mem-ratio"` + ListedIDsMemRatio float64 `name:"listed-ids-mem-ratio"` + MarkerMemRatio float64 `name:"marker-mem-ratio"` + MediaMemRatio float64 `name:"media-mem-ratio"` + MentionMemRatio float64 `name:"mention-mem-ratio"` + MoveMemRatio float64 `name:"move-mem-ratio"` + NotificationMemRatio float64 `name:"notification-mem-ratio"` + PollMemRatio float64 `name:"poll-mem-ratio"` + PollVoteMemRatio float64 `name:"poll-vote-mem-ratio"` + PollVoteIDsMemRatio float64 `name:"poll-vote-ids-mem-ratio"` + ReportMemRatio float64 `name:"report-mem-ratio"` + SinBinStatusMemRatio float64 `name:"sin-bin-status-mem-ratio"` + StatusMemRatio float64 `name:"status-mem-ratio"` + StatusBookmarkMemRatio float64 `name:"status-bookmark-mem-ratio"` + StatusBookmarkIDsMemRatio float64 `name:"status-bookmark-ids-mem-ratio"` + StatusEditMemRatio float64 `name:"status-edit-mem-ratio"` + StatusFaveMemRatio float64 `name:"status-fave-mem-ratio"` + StatusFaveIDsMemRatio float64 `name:"status-fave-ids-mem-ratio"` + TagMemRatio float64 `name:"tag-mem-ratio"` + ThreadMuteMemRatio float64 `name:"thread-mute-mem-ratio"` + TokenMemRatio float64 `name:"token-mem-ratio"` + TombstoneMemRatio float64 `name:"tombstone-mem-ratio"` + UserMemRatio float64 `name:"user-mem-ratio"` + UserMuteMemRatio float64 `name:"user-mute-mem-ratio"` + UserMuteIDsMemRatio float64 `name:"user-mute-ids-mem-ratio"` + WebfingerMemRatio float64 `name:"webfinger-mem-ratio"` + VisibilityMemRatio float64 `name:"visibility-mem-ratio"` } // MarshalMap will marshal current Configuration into a map structure (useful for JSON/TOML/YAML). diff --git a/internal/config/defaults.go b/internal/config/defaults.go index f77c5c456..97d96d1ba 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -158,59 +158,60 @@ var Defaults = Configuration{ // when TODO items in the size.go source // file have been addressed, these should // be able to make some more sense :D - AccountMemRatio: 5, - AccountNoteMemRatio: 1, - AccountSettingsMemRatio: 0.1, - AccountStatsMemRatio: 2, - ApplicationMemRatio: 0.1, - BlockMemRatio: 2, - BlockIDsMemRatio: 3, - BoostOfIDsMemRatio: 3, - ClientMemRatio: 0.1, - ConversationMemRatio: 1, - ConversationLastStatusIDsMemRatio: 2, - DomainPermissionDraftMemRation: 0.5, - EmojiMemRatio: 3, - EmojiCategoryMemRatio: 0.1, - FilterMemRatio: 0.5, - FilterKeywordMemRatio: 0.5, - FilterStatusMemRatio: 0.5, - FollowMemRatio: 2, - FollowIDsMemRatio: 4, - FollowRequestMemRatio: 2, - FollowRequestIDsMemRatio: 2, - FollowingTagIDsMemRatio: 2, - InReplyToIDsMemRatio: 3, - InstanceMemRatio: 1, - InteractionRequestMemRatio: 1, - ListMemRatio: 1, - ListIDsMemRatio: 2, - ListedIDsMemRatio: 2, - MarkerMemRatio: 0.5, - MediaMemRatio: 4, - MentionMemRatio: 2, - MoveMemRatio: 0.1, - NotificationMemRatio: 2, - PollMemRatio: 1, - PollVoteMemRatio: 2, - PollVoteIDsMemRatio: 2, - ReportMemRatio: 1, - SinBinStatusMemRatio: 0.5, - StatusMemRatio: 5, - StatusBookmarkMemRatio: 0.5, - StatusBookmarkIDsMemRatio: 2, - StatusEditMemRatio: 2, - StatusFaveMemRatio: 2, - StatusFaveIDsMemRatio: 3, - TagMemRatio: 2, - ThreadMuteMemRatio: 0.2, - TokenMemRatio: 0.75, - TombstoneMemRatio: 0.5, - UserMemRatio: 0.25, - UserMuteMemRatio: 2, - UserMuteIDsMemRatio: 3, - WebfingerMemRatio: 0.1, - VisibilityMemRatio: 2, + AccountMemRatio: 5, + AccountNoteMemRatio: 1, + AccountSettingsMemRatio: 0.1, + AccountStatsMemRatio: 2, + ApplicationMemRatio: 0.1, + BlockMemRatio: 2, + BlockIDsMemRatio: 3, + BoostOfIDsMemRatio: 3, + ClientMemRatio: 0.1, + ConversationMemRatio: 1, + ConversationLastStatusIDsMemRatio: 2, + DomainPermissionDraftMemRation: 0.5, + DomainPermissionSubscriptionMemRation: 0.5, + EmojiMemRatio: 3, + EmojiCategoryMemRatio: 0.1, + FilterMemRatio: 0.5, + FilterKeywordMemRatio: 0.5, + FilterStatusMemRatio: 0.5, + FollowMemRatio: 2, + FollowIDsMemRatio: 4, + FollowRequestMemRatio: 2, + FollowRequestIDsMemRatio: 2, + FollowingTagIDsMemRatio: 2, + InReplyToIDsMemRatio: 3, + InstanceMemRatio: 1, + InteractionRequestMemRatio: 1, + ListMemRatio: 1, + ListIDsMemRatio: 2, + ListedIDsMemRatio: 2, + MarkerMemRatio: 0.5, + MediaMemRatio: 4, + MentionMemRatio: 2, + MoveMemRatio: 0.1, + NotificationMemRatio: 2, + PollMemRatio: 1, + PollVoteMemRatio: 2, + PollVoteIDsMemRatio: 2, + ReportMemRatio: 1, + SinBinStatusMemRatio: 0.5, + StatusMemRatio: 5, + StatusBookmarkMemRatio: 0.5, + StatusBookmarkIDsMemRatio: 2, + StatusEditMemRatio: 2, + StatusFaveMemRatio: 2, + StatusFaveIDsMemRatio: 3, + TagMemRatio: 2, + ThreadMuteMemRatio: 0.2, + TokenMemRatio: 0.75, + TombstoneMemRatio: 0.5, + UserMemRatio: 0.25, + UserMuteMemRatio: 2, + UserMuteIDsMemRatio: 3, + WebfingerMemRatio: 0.1, + VisibilityMemRatio: 2, }, HTTPClient: HTTPClientConfiguration{ diff --git a/internal/config/helpers.gen.go b/internal/config/helpers.gen.go index 543292ebe..625c4ea78 100644 --- a/internal/config/helpers.gen.go +++ b/internal/config/helpers.gen.go @@ -3187,6 +3187,37 @@ func SetCacheDomainPermissionDraftMemRation(v float64) { global.SetCacheDomainPermissionDraftMemRation(v) } +// GetCacheDomainPermissionSubscriptionMemRation safely fetches the Configuration value for state's 'Cache.DomainPermissionSubscriptionMemRation' field +func (st *ConfigState) GetCacheDomainPermissionSubscriptionMemRation() (v float64) { + st.mutex.RLock() + v = st.config.Cache.DomainPermissionSubscriptionMemRation + st.mutex.RUnlock() + return +} + +// SetCacheDomainPermissionSubscriptionMemRation safely sets the Configuration value for state's 'Cache.DomainPermissionSubscriptionMemRation' field +func (st *ConfigState) SetCacheDomainPermissionSubscriptionMemRation(v float64) { + st.mutex.Lock() + defer st.mutex.Unlock() + st.config.Cache.DomainPermissionSubscriptionMemRation = v + st.reloadToViper() +} + +// CacheDomainPermissionSubscriptionMemRationFlag returns the flag name for the 'Cache.DomainPermissionSubscriptionMemRation' field +func CacheDomainPermissionSubscriptionMemRationFlag() string { + return "cache-domain-permission-subscription-mem-ratio" +} + +// GetCacheDomainPermissionSubscriptionMemRation safely fetches the value for global configuration 'Cache.DomainPermissionSubscriptionMemRation' field +func GetCacheDomainPermissionSubscriptionMemRation() float64 { + return global.GetCacheDomainPermissionSubscriptionMemRation() +} + +// SetCacheDomainPermissionSubscriptionMemRation safely sets the value for global configuration 'Cache.DomainPermissionSubscriptionMemRation' field +func SetCacheDomainPermissionSubscriptionMemRation(v float64) { + global.SetCacheDomainPermissionSubscriptionMemRation(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/domainpermissionsubscription.go b/internal/db/bundb/domainpermissionsubscription.go new file mode 100644 index 000000000..be22b96a3 --- /dev/null +++ b/internal/db/bundb/domainpermissionsubscription.go @@ -0,0 +1,354 @@ +// 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/uptrace/bun" +) + +func (d *domainDB) getDomainPermissionSubscription( + ctx context.Context, + lookup string, + dbQuery func(*gtsmodel.DomainPermissionSubscription) error, + keyParts ...any, +) (*gtsmodel.DomainPermissionSubscription, error) { + // Fetch perm subscription from database cache with loader callback. + permSub, err := d.state.Caches.DB.DomainPermissionSubscription.LoadOne( + lookup, + // Only called if not cached. + func() (*gtsmodel.DomainPermissionSubscription, error) { + var permSub gtsmodel.DomainPermissionSubscription + if err := dbQuery(&permSub); err != nil { + return nil, err + } + return &permSub, nil + }, + keyParts..., + ) + if err != nil { + return nil, err + } + + if gtscontext.Barebones(ctx) { + // No need to fully populate. + return permSub, nil + } + + if permSub.CreatedByAccount == nil { + // Not set, fetch from database. + permSub.CreatedByAccount, err = d.state.DB.GetAccountByID( + gtscontext.SetBarebones(ctx), + permSub.CreatedByAccountID, + ) + if err != nil { + return nil, gtserror.Newf("error populating created by account: %w", err) + } + } + + return permSub, nil +} + +func (d *domainDB) GetDomainPermissionSubscriptionByID( + ctx context.Context, + id string, +) (*gtsmodel.DomainPermissionSubscription, error) { + return d.getDomainPermissionSubscription( + ctx, + "ID", + func(permSub *gtsmodel.DomainPermissionSubscription) error { + return d.db. + NewSelect(). + Model(permSub). + Where("? = ?", bun.Ident("domain_permission_subscription.id"), id). + Scan(ctx) + }, + id, + ) +} + +func (d *domainDB) GetDomainPermissionSubscriptions( + ctx context.Context, + permType gtsmodel.DomainPermissionType, + page *paging.Page, +) ( + []*gtsmodel.DomainPermissionSubscription, + error, +) { + var ( + // Get paging params. + minID = page.GetMin() + maxID = page.GetMax() + limit = page.GetLimit() + order = page.GetOrder() + + // Make educated guess for slice size + permSubIDs = make([]string, 0, limit) + ) + + q := d.db. + NewSelect(). + TableExpr( + "? AS ?", + bun.Ident("domain_permission_subscriptions"), + bun.Ident("domain_permission_subscription"), + ). + // Select only IDs from table + Column("domain_permission_subscription.id") + + // Return only items with id + // lower than provided maxID. + if maxID != "" { + q = q.Where( + "? < ?", + bun.Ident("domain_permission_subscription.id"), + maxID, + ) + } + + // Return only items with id + // greater than provided minID. + if minID != "" { + q = q.Where( + "? > ?", + bun.Ident("domain_permission_subscription.id"), + minID, + ) + } + + // Return only items with + // given permission type. + if permType != gtsmodel.DomainPermissionUnknown { + q = q.Where( + "? = ?", + bun.Ident("domain_permission_subscription.permission_type"), + permType, + ) + } + + 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_subscription.id"), + ) + } else { + // Page down. + q = q.OrderExpr( + "? DESC", + bun.Ident("domain_permission_subscription.id"), + ) + } + + if err := q.Scan(ctx, &permSubIDs); err != nil { + return nil, err + } + + // Catch case of no items early + if len(permSubIDs) == 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(permSubIDs) + } + + // Allocate return slice (will be at most len permSubIDs). + permSubs := make([]*gtsmodel.DomainPermissionSubscription, 0, len(permSubIDs)) + for _, id := range permSubIDs { + permSub, err := d.GetDomainPermissionSubscriptionByID(ctx, id) + if err != nil { + log.Errorf(ctx, "error getting domain permission subscription %q: %v", id, err) + continue + } + + // Append to return slice + permSubs = append(permSubs, permSub) + } + + return permSubs, nil +} + +func (d *domainDB) GetDomainPermissionSubscriptionsByPriority( + ctx context.Context, + permType gtsmodel.DomainPermissionType, +) ( + []*gtsmodel.DomainPermissionSubscription, + error, +) { + permSubIDs := []string{} + + q := d.db. + NewSelect(). + TableExpr( + "? AS ?", + bun.Ident("domain_permission_subscriptions"), + bun.Ident("domain_permission_subscription"), + ). + // Select only IDs from table + Column("domain_permission_subscription.id"). + // Select only subs of given perm type. + Where( + "? = ?", + bun.Ident("domain_permission_subscription.permission_type"), + permType, + ). + // Order by priority descending. + OrderExpr( + "? DESC", + bun.Ident("domain_permission_subscription.priority"), + ) + + if err := q.Scan(ctx, &permSubIDs); err != nil { + return nil, err + } + + // Catch case of no items early + if len(permSubIDs) == 0 { + return nil, db.ErrNoEntries + } + + // Allocate return slice (will be at most len permSubIDs). + permSubs := make([]*gtsmodel.DomainPermissionSubscription, 0, len(permSubIDs)) + for _, id := range permSubIDs { + permSub, err := d.GetDomainPermissionSubscriptionByID(ctx, id) + if err != nil { + log.Errorf(ctx, "error getting domain permission subscription %q: %v", id, err) + continue + } + + // Append to return slice + permSubs = append(permSubs, permSub) + } + + return permSubs, nil +} + +func (d *domainDB) PutDomainPermissionSubscription( + ctx context.Context, + permSubscription *gtsmodel.DomainPermissionSubscription, +) error { + return d.state.Caches.DB.DomainPermissionSubscription.Store( + permSubscription, + func() error { + _, err := d.db. + NewInsert(). + Model(permSubscription). + Exec(ctx) + return err + }, + ) +} + +func (d *domainDB) UpdateDomainPermissionSubscription( + ctx context.Context, + permSubscription *gtsmodel.DomainPermissionSubscription, + columns ...string, +) error { + return d.state.Caches.DB.DomainPermissionSubscription.Store( + permSubscription, + func() error { + _, err := d.db. + NewUpdate(). + Model(permSubscription). + Where("? = ?", bun.Ident("id"), permSubscription.ID). + Column(columns...). + Exec(ctx) + return err + }, + ) +} + +func (d *domainDB) DeleteDomainPermissionSubscription( + ctx context.Context, + id string, +) error { + // Delete the permSub from DB. + q := d.db.NewDelete(). + TableExpr( + "? AS ?", + bun.Ident("domain_permission_subscriptions"), + bun.Ident("domain_permission_subscription"), + ). + Where( + "? = ?", + bun.Ident("domain_permission_subscription.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.DomainPermissionSubscription.Invalidate("ID", id) + + return nil +} + +func (d *domainDB) CountDomainPermissionSubscriptionPerms( + ctx context.Context, + id string, +) (int, error) { + permSubscription, err := d.GetDomainPermissionSubscriptionByID( + gtscontext.SetBarebones(ctx), + id, + ) + if err != nil { + return 0, err + } + + q := d.db.NewSelect() + + if permSubscription.PermissionType == gtsmodel.DomainPermissionBlock { + q = q.TableExpr( + "? AS ?", + bun.Ident("domain_blocks"), + bun.Ident("perm"), + ) + } else { + q = q.TableExpr( + "? AS ?", + bun.Ident("domain_allows"), + bun.Ident("perm"), + ) + } + + return q. + Column("perm.id"). + Where("? = ?", bun.Ident("perm.subscription_id"), id). + Count(ctx) +} diff --git a/internal/db/bundb/domainpermissionsubscription_test.go b/internal/db/bundb/domainpermissionsubscription_test.go new file mode 100644 index 000000000..732befbff --- /dev/null +++ b/internal/db/bundb/domainpermissionsubscription_test.go @@ -0,0 +1,99 @@ +// 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" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +type DomainPermissionSubscriptionTestSuite struct { + BunDBStandardTestSuite +} + +func (suite *DomainPermissionSubscriptionTestSuite) TestCount() { + var ( + ctx = context.Background() + testAccount = suite.testAccounts["admin_account"] + permSub = >smodel.DomainPermissionSubscription{ + ID: "01JGV3VZ72K58BYW8H5GEVBZGN", + PermissionType: gtsmodel.DomainPermissionBlock, + CreatedByAccountID: testAccount.ID, + CreatedByAccount: testAccount, + URI: "https://example.org/whatever.csv", + ContentType: gtsmodel.DomainPermSubContentTypeCSV, + } + perms = []*gtsmodel.DomainBlock{ + { + ID: "01JGV42G72YCKN06AC51RZPFES", + Domain: "whatever.com", + CreatedByAccountID: testAccount.ID, + CreatedByAccount: testAccount, + SubscriptionID: permSub.ID, + }, + { + ID: "01JGV43ZQKYPHM2M0YBQDFDSD1", + Domain: "aaaa.example.org", + CreatedByAccountID: testAccount.ID, + CreatedByAccount: testAccount, + SubscriptionID: permSub.ID, + }, + { + ID: "01JGV444KDDC4WFG6MZQVM0N2Z", + Domain: "bbbb.example.org", + CreatedByAccountID: testAccount.ID, + CreatedByAccount: testAccount, + SubscriptionID: permSub.ID, + }, + { + ID: "01JGV44AFEMBWS6P6S72BQK376", + Domain: "cccc.example.org", + CreatedByAccountID: testAccount.ID, + CreatedByAccount: testAccount, + SubscriptionID: permSub.ID, + }, + } + ) + + // Whack the perm sub in the DB. + if err := suite.state.DB.PutDomainPermissionSubscription(ctx, permSub); err != nil { + suite.FailNow(err.Error()) + } + + // Whack the perms in the db. + for _, perm := range perms { + if err := suite.state.DB.CreateDomainBlock(ctx, perm); err != nil { + suite.FailNow(err.Error()) + } + } + + // Count 'em. + count, err := suite.state.DB.CountDomainPermissionSubscriptionPerms(ctx, permSub.ID) + if err != nil { + suite.FailNow(err.Error()) + } + + suite.Equal(4, count) +} + +func TestDomainPermissionSubscriptionTestSuite(t *testing.T) { + suite.Run(t, new(DomainPermissionSubscriptionTestSuite)) +} diff --git a/internal/db/bundb/migrations/20241022153016_domain_permission_draft_exclude.go b/internal/db/bundb/migrations/20241022153016_domain_permission_draft_exclude.go index e19ea2b4d..32485ec64 100644 --- a/internal/db/bundb/migrations/20241022153016_domain_permission_draft_exclude.go +++ b/internal/db/bundb/migrations/20241022153016_domain_permission_draft_exclude.go @@ -20,7 +20,7 @@ package migrations import ( "context" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations/20241022153016_domain_permission_draft_exclude" "github.com/uptrace/bun" ) diff --git a/internal/db/bundb/migrations/20241022153016_domain_permission_draft_exclude/domainpermissiondraft.go b/internal/db/bundb/migrations/20241022153016_domain_permission_draft_exclude/domainpermissiondraft.go new file mode 100644 index 000000000..e93b86f5c --- /dev/null +++ b/internal/db/bundb/migrations/20241022153016_domain_permission_draft_exclude/domainpermissiondraft.go @@ -0,0 +1,33 @@ +// 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"` + CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` + UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` + PermissionType uint8 `bun:",notnull,unique:domain_permission_drafts_permission_type_domain_subscription_id_uniq"` + Domain string `bun:",nullzero,notnull,unique:domain_permission_drafts_permission_type_domain_subscription_id_uniq"` + CreatedByAccountID string `bun:"type:CHAR(26),nullzero,notnull"` + PrivateComment string `bun:",nullzero"` + PublicComment string `bun:",nullzero"` + Obfuscate *bool `bun:",nullzero,notnull,default:false"` + SubscriptionID string `bun:"type:CHAR(26),unique:domain_permission_drafts_permission_type_domain_subscription_id_uniq"` +} diff --git a/internal/db/bundb/migrations/20241022153016_domain_permission_draft_exclude/domainpermissionexclude.go b/internal/db/bundb/migrations/20241022153016_domain_permission_draft_exclude/domainpermissionexclude.go new file mode 100644 index 000000000..3ff46ba23 --- /dev/null +++ b/internal/db/bundb/migrations/20241022153016_domain_permission_draft_exclude/domainpermissionexclude.go @@ -0,0 +1,31 @@ +// 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 DomainPermissionExclude struct { + ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` + CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` + UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` + Domain string `bun:",nullzero,notnull,unique"` + CreatedByAccountID string `bun:"type:CHAR(26),nullzero,notnull"` + PrivateComment string `bun:",nullzero"` +} diff --git a/internal/db/bundb/migrations/20241022153016_domain_permission_subscriptions.go b/internal/db/bundb/migrations/20241022153016_domain_permission_subscriptions.go new file mode 100644 index 000000000..7d2bd085c --- /dev/null +++ b/internal/db/bundb/migrations/20241022153016_domain_permission_subscriptions.go @@ -0,0 +1,75 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package migrations + +import ( + "context" + + gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations/20241022153016_domain_permission_subscriptions" + "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_subscriptions`. + if _, err := tx. + NewCreateTable(). + Model((*gtsmodel.DomainPermissionSubscription)(nil)). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + + // Create indexes. Indices. Indie sexes. + if _, err := tx. + NewCreateIndex(). + Table("domain_permission_subscriptions"). + // Filter on permission type. + Index("domain_permission_subscriptions_permission_type_idx"). + Column("permission_type"). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + + if _, err := tx. + NewCreateIndex(). + Table("domain_permission_subscriptions"). + // Sort by priority DESC. + Index("domain_permission_subscriptions_priority_order_idx"). + ColumnExpr("? DESC", bun.Ident("priority")). + 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/bundb/migrations/20241022153016_domain_permission_subscriptions/domainpermissionsubscription.go b/internal/db/bundb/migrations/20241022153016_domain_permission_subscriptions/domainpermissionsubscription.go new file mode 100644 index 000000000..851f44f15 --- /dev/null +++ b/internal/db/bundb/migrations/20241022153016_domain_permission_subscriptions/domainpermissionsubscription.go @@ -0,0 +1,38 @@ +// 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 DomainPermissionSubscription struct { + ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` + Priority uint8 `bun:""` + Title string `bun:",nullzero,unique"` + PermissionType uint8 `bun:",nullzero,notnull"` + AsDraft *bool `bun:",nullzero,notnull,default:true"` + AdoptOrphans *bool `bun:",nullzero,notnull,default:false"` + CreatedByAccountID string `bun:"type:CHAR(26),nullzero,notnull"` + URI string `bun:",nullzero,notnull,unique"` + ContentType uint16 `bun:",nullzero,notnull"` + FetchUsername string `bun:",nullzero"` + FetchPassword string `bun:",nullzero"` + FetchedAt time.Time `bun:"type:timestamptz,nullzero"` + SuccessfullyFetchedAt time.Time `bun:"type:timestamptz,nullzero"` + ETag string `bun:"etag,nullzero"` + Error string `bun:",nullzero"` +} diff --git a/internal/db/domain.go b/internal/db/domain.go index f4d05ad1d..643538e7e 100644 --- a/internal/db/domain.go +++ b/internal/db/domain.go @@ -132,4 +132,44 @@ type Domain interface { // IsDomainPermissionExcluded returns true if the given domain matches in the list of excluded domains. IsDomainPermissionExcluded(ctx context.Context, domain string) (bool, error) + + /* + Domain permission subscription stuff. + */ + + // GetDomainPermissionSubscriptionByID gets one DomainPermissionSubscription with the given ID. + GetDomainPermissionSubscriptionByID(ctx context.Context, id string) (*gtsmodel.DomainPermissionSubscription, error) + + // GetDomainPermissionSubscriptions returns a page of + // DomainPermissionSubscriptions using the given parameters. + GetDomainPermissionSubscriptions( + ctx context.Context, + permType gtsmodel.DomainPermissionType, + page *paging.Page, + ) ([]*gtsmodel.DomainPermissionSubscription, error) + + // GetDomainPermissionSubscriptionsByPriority returns *all* domain permission + // subscriptions of the given permission type, sorted by priority descending. + GetDomainPermissionSubscriptionsByPriority( + ctx context.Context, + permType gtsmodel.DomainPermissionType, + ) ([]*gtsmodel.DomainPermissionSubscription, error) + + // PutDomainPermissionSubscription stores one DomainPermissionSubscription. + PutDomainPermissionSubscription(ctx context.Context, permSub *gtsmodel.DomainPermissionSubscription) error + + // UpdateDomainPermissionSubscription updates the provided + // columns of one DomainPermissionSubscription. + UpdateDomainPermissionSubscription( + ctx context.Context, + permSub *gtsmodel.DomainPermissionSubscription, + columns ...string, + ) error + + // DeleteDomainPermissionSubscription deletes one DomainPermissionSubscription with the given id. + DeleteDomainPermissionSubscription(ctx context.Context, id string) error + + // CountDomainPermissionSubscriptionPerms counts the number of permissions + // currently managed by the domain permission subscription of the given ID. + CountDomainPermissionSubscriptionPerms(ctx context.Context, id string) (int, error) } diff --git a/internal/gtsmodel/domainpermissionsubscription.go b/internal/gtsmodel/domainpermissionsubscription.go new file mode 100644 index 000000000..b6a0b8f43 --- /dev/null +++ b/internal/gtsmodel/domainpermissionsubscription.go @@ -0,0 +1,74 @@ +// 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 DomainPermissionSubscription struct { + ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // ID of this item in the database. + Priority uint8 `bun:""` // Priority of this subscription compared to others of the same permission type. 0-255 (higher = higher priority). + Title string `bun:",nullzero,unique"` // Moderator-set title for this list. + PermissionType DomainPermissionType `bun:",nullzero,notnull"` // Permission type of the subscription. + AsDraft *bool `bun:",nullzero,notnull,default:true"` // Create domain permission entries resulting from this subscription as drafts. + AdoptOrphans *bool `bun:",nullzero,notnull,default:false"` // Adopt orphaned domain permissions present in this subscription's entries. + CreatedByAccountID string `bun:"type:CHAR(26),nullzero,notnull"` // Account ID of the creator of this subscription. + CreatedByAccount *Account `bun:"-"` // Account corresponding to createdByAccountID. + URI string `bun:",nullzero,notnull,unique"` // URI of the domain permission list. + ContentType DomainPermSubContentType `bun:",nullzero,notnull"` // Content type to expect from the URI. + FetchUsername string `bun:",nullzero"` // Username to send when doing a GET of URI using basic auth. + FetchPassword string `bun:",nullzero"` // Password to send when doing a GET of URI using basic auth. + FetchedAt time.Time `bun:"type:timestamptz,nullzero"` // Time when fetch of URI was last attempted. + SuccessfullyFetchedAt time.Time `bun:"type:timestamptz,nullzero"` // Time when the domain permission list was last *successfuly* fetched, to be transmitted as If-Modified-Since header. + ETag string `bun:"etag,nullzero"` // Etag last received from the server (if any) on successful fetch. + Error string `bun:",nullzero"` // If latest fetch attempt errored, this field stores the error message. Cleared on latest successful fetch. +} + +type DomainPermSubContentType enumType + +const ( + DomainPermSubContentTypeUnknown DomainPermSubContentType = 0 // ??? + DomainPermSubContentTypeCSV DomainPermSubContentType = 1 // text/csv + DomainPermSubContentTypeJSON DomainPermSubContentType = 2 // application/json + DomainPermSubContentTypePlain DomainPermSubContentType = 3 // text/plain +) + +func (p DomainPermSubContentType) String() string { + switch p { + case DomainPermSubContentTypeCSV: + return "text/csv" + case DomainPermSubContentTypeJSON: + return "application/json" + case DomainPermSubContentTypePlain: + return "text/plain" + default: + panic("unknown content type") + } +} + +func NewDomainPermSubContentType(in string) DomainPermSubContentType { + switch in { + case "text/csv": + return DomainPermSubContentTypeCSV + case "application/json": + return DomainPermSubContentTypeJSON + case "text/plain": + return DomainPermSubContentTypePlain + default: + return DomainPermSubContentTypeUnknown + } +} diff --git a/internal/id/ulid.go b/internal/id/ulid.go index 8c0b1e94c..3c57c9f1b 100644 --- a/internal/id/ulid.go +++ b/internal/id/ulid.go @@ -83,3 +83,12 @@ func NewRandomULID() (string, error) { } return newUlid.String(), nil } + +func TimeFromULID(id string) (time.Time, error) { + parsed, err := ulid.ParseStrict(id) + if err != nil { + return time.Time{}, err + } + + return ulid.Time(parsed.Time()), nil +} diff --git a/internal/processing/admin/domainpermissionsubscription.go b/internal/processing/admin/domainpermissionsubscription.go new file mode 100644 index 000000000..3d2f63d56 --- /dev/null +++ b/internal/processing/admin/domainpermissionsubscription.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 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" +) + +// DomainPermissionSubscriptionGet returns one +// domain permission subscription with the given id. +func (p *Processor) DomainPermissionSubscriptionGet( + ctx context.Context, + id string, +) (*apimodel.DomainPermissionSubscription, gtserror.WithCode) { + permSub, err := p.state.DB.GetDomainPermissionSubscriptionByID(ctx, id) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting domain permission subscription %s: %w", id, err) + return nil, gtserror.NewErrorInternalError(err) + } + + if permSub == nil { + err := fmt.Errorf("domain permission subscription %s not found", id) + return nil, gtserror.NewErrorNotFound(err, err.Error()) + } + + return p.apiDomainPermSub(ctx, permSub) +} + +// DomainPermissionSubscriptionsGet returns a page of +// DomainPermissionSubscriptions with the given parameters. +func (p *Processor) DomainPermissionSubscriptionsGet( + ctx context.Context, + permType gtsmodel.DomainPermissionType, + page *paging.Page, +) (*apimodel.PageableResponse, gtserror.WithCode) { + permSubs, err := p.state.DB.GetDomainPermissionSubscriptions( + ctx, + permType, + page, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + count := len(permSubs) + if count == 0 { + return paging.EmptyResponse(), nil + } + + // Get the lowest and highest + // ID values, used for paging. + lo := permSubs[count-1].ID + hi := permSubs[0].ID + + // Convert each perm sub to API model. + items := make([]any, len(permSubs)) + for i, permSub := range permSubs { + apiPermSub, err := p.converter.DomainPermSubToAPIDomainPermSub(ctx, permSub) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + items[i] = apiPermSub + } + + // Assemble next/prev page queries. + query := make(url.Values, 1) + if permType != gtsmodel.DomainPermissionUnknown { + query.Set(apiutil.DomainPermissionPermTypeKey, permType.String()) + } + + return paging.PackageResponse(paging.ResponseParams{ + Items: items, + Path: "/api/v1/admin/domain_permission_subscriptions", + Next: page.Next(lo, hi), + Prev: page.Prev(lo, hi), + Query: query, + }), nil +} + +// DomainPermissionSubscriptionsGetByPriority returns all domain permission +// subscriptions of the given permission type, in descending priority order. +func (p *Processor) DomainPermissionSubscriptionsGetByPriority( + ctx context.Context, + permType gtsmodel.DomainPermissionType, +) ([]*apimodel.DomainPermissionSubscription, gtserror.WithCode) { + permSubs, err := p.state.DB.GetDomainPermissionSubscriptionsByPriority( + ctx, + permType, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Convert each perm sub to API model. + items := make([]*apimodel.DomainPermissionSubscription, len(permSubs)) + for i, permSub := range permSubs { + apiPermSub, err := p.converter.DomainPermSubToAPIDomainPermSub(ctx, permSub) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + items[i] = apiPermSub + } + + return items, nil +} + +func (p *Processor) DomainPermissionSubscriptionCreate( + ctx context.Context, + acct *gtsmodel.Account, + priority uint8, + title string, + uri string, + contentType gtsmodel.DomainPermSubContentType, + permType gtsmodel.DomainPermissionType, + asDraft bool, + fetchUsername string, + fetchPassword string, +) (*apimodel.DomainPermissionSubscription, gtserror.WithCode) { + permSub := >smodel.DomainPermissionSubscription{ + ID: id.NewULID(), + Priority: priority, + Title: title, + PermissionType: permType, + AsDraft: &asDraft, + CreatedByAccountID: acct.ID, + CreatedByAccount: acct, + URI: uri, + ContentType: contentType, + FetchUsername: fetchUsername, + FetchPassword: fetchPassword, + } + + err := p.state.DB.PutDomainPermissionSubscription(ctx, permSub) + if err != nil { + if errors.Is(err, db.ErrAlreadyExists) { + // Unique constraint conflict. + const errText = "domain permission subscription with given URI or title already exists" + return nil, gtserror.NewErrorConflict(errors.New(errText), errText) + } + + // Real database error. + err := gtserror.Newf("db error putting domain permission subscription: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return p.apiDomainPermSub(ctx, permSub) +} + +func (p *Processor) DomainPermissionSubscriptionUpdate( + ctx context.Context, + id string, + priority *uint8, + title *string, + uri *string, + contentType *gtsmodel.DomainPermSubContentType, + asDraft *bool, + adoptOrphans *bool, + fetchUsername *string, + fetchPassword *string, +) (*apimodel.DomainPermissionSubscription, gtserror.WithCode) { + permSub, err := p.state.DB.GetDomainPermissionSubscriptionByID(ctx, id) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting domain permission subscription %s: %w", id, err) + return nil, gtserror.NewErrorInternalError(err) + } + + if permSub == nil { + err := fmt.Errorf("domain permission subscription %s not found", id) + return nil, gtserror.NewErrorNotFound(err, err.Error()) + } + + columns := make([]string, 0, 7) + + if priority != nil { + permSub.Priority = *priority + columns = append(columns, "priority") + } + + if title != nil { + permSub.Title = *title + columns = append(columns, "title") + } + + if uri != nil { + permSub.URI = *uri + columns = append(columns, "uri") + } + + if contentType != nil { + permSub.ContentType = *contentType + columns = append(columns, "content_type") + } + + if asDraft != nil { + permSub.AsDraft = asDraft + columns = append(columns, "as_draft") + } + + if adoptOrphans != nil { + permSub.AdoptOrphans = adoptOrphans + columns = append(columns, "adopt_orphans") + } + + if fetchPassword != nil { + permSub.FetchPassword = *fetchPassword + columns = append(columns, "fetch_password") + } + + if fetchUsername != nil { + permSub.FetchUsername = *fetchUsername + columns = append(columns, "fetch_username") + } + + err = p.state.DB.UpdateDomainPermissionSubscription(ctx, permSub, columns...) + if err != nil { + if errors.Is(err, db.ErrAlreadyExists) { + // Unique constraint conflict. + const errText = "domain permission subscription with given URI or title already exists" + return nil, gtserror.NewErrorConflict(errors.New(errText), errText) + } + + // Real database error. + err := gtserror.Newf("db error updating domain permission subscription: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return p.apiDomainPermSub(ctx, permSub) +} + +func (p *Processor) DomainPermissionSubscriptionRemove( + ctx context.Context, + id string, + removeChildren bool, +) (*apimodel.DomainPermissionSubscription, gtserror.WithCode) { + permSub, err := p.state.DB.GetDomainPermissionSubscriptionByID(ctx, id) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting domain permission subscription %s: %w", id, err) + return nil, gtserror.NewErrorInternalError(err) + } + + if permSub == nil { + err := fmt.Errorf("domain permission subscription %s not found", id) + return nil, gtserror.NewErrorNotFound(err, err.Error()) + } + + // TODO in next PR: if removeChildren, then remove all + // domain permissions that are children of this domain + // permission subscription. If not removeChildren, then + // just unlink them by clearing their subscription ID. + // For now just delete the domain permission subscription. + if err := p.state.DB.DeleteDomainPermissionSubscription(ctx, id); err != nil { + err := gtserror.Newf("db error deleting domain permission subscription: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return p.apiDomainPermSub(ctx, permSub) +} diff --git a/internal/processing/admin/util.go b/internal/processing/admin/util.go index bc59a2b3b..aef435856 100644 --- a/internal/processing/admin/util.go +++ b/internal/processing/admin/util.go @@ -115,3 +115,19 @@ func (p *Processor) apiDomainPerm( return apiDomainPerm, nil } + +// apiDomainPermSub is a cheeky shortcut for returning the +// API version of the given domain permission subscription, +// or an appropriate error if something goes wrong. +func (p *Processor) apiDomainPermSub( + ctx context.Context, + domainPermSub *gtsmodel.DomainPermissionSubscription, +) (*apimodel.DomainPermissionSubscription, gtserror.WithCode) { + apiDomainPermSub, err := p.converter.DomainPermSubToAPIDomainPermSub(ctx, domainPermSub) + if err != nil { + err := gtserror.NewfAt(3, "error converting domain permission subscription to api model: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return apiDomainPermSub, nil +} diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index 0d5e15078..9fb69b438 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -36,6 +36,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/filter/usermute" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/language" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/media" @@ -2130,6 +2131,60 @@ func (c *Converter) DomainPermToAPIDomainPerm( return domainPerm, nil } +func (c *Converter) DomainPermSubToAPIDomainPermSub( + ctx context.Context, + d *gtsmodel.DomainPermissionSubscription, +) (*apimodel.DomainPermissionSubscription, error) { + createdAt, err := id.TimeFromULID(d.ID) + if err != nil { + return nil, gtserror.Newf("error converting id to time: %w", err) + } + + // URI may be in Punycode, + // de-punify it just in case. + uri, err := util.DePunify(d.URI) + if err != nil { + return nil, gtserror.Newf("error de-punifying URI %s: %w", d.URI, err) + } + + var ( + fetchedAt string + successfullyFetchedAt string + ) + + if !d.FetchedAt.IsZero() { + fetchedAt = util.FormatISO8601(d.FetchedAt) + } + + if !d.SuccessfullyFetchedAt.IsZero() { + successfullyFetchedAt = util.FormatISO8601(d.SuccessfullyFetchedAt) + } + + count, err := c.state.DB.CountDomainPermissionSubscriptionPerms(ctx, d.ID) + if err != nil { + return nil, gtserror.Newf("error counting perm sub perms: %w", err) + } + + return &apimodel.DomainPermissionSubscription{ + ID: d.ID, + Priority: d.Priority, + Title: d.Title, + PermissionType: d.PermissionType.String(), + AsDraft: *d.AsDraft, + AdoptOrphans: *d.AdoptOrphans, + CreatedBy: d.CreatedByAccountID, + CreatedAt: util.FormatISO8601(createdAt), + URI: uri, + ContentType: d.ContentType.String(), + FetchUsername: d.FetchUsername, + FetchPassword: d.FetchPassword, + FetchedAt: fetchedAt, + SuccessfullyFetchedAt: successfullyFetchedAt, + Error: d.Error, + Count: uint64(count), // #nosec G115 -- Don't care about overflow here. + }, nil +} + // ReportToAPIReport converts a gts model report into an api model report, for serving at /api/v1/reports func (c *Converter) ReportToAPIReport(ctx context.Context, r *gtsmodel.Report) (*apimodel.Report, error) { report := &apimodel.Report{ |