diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index 2370cc36c..a573c98f9 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -1124,6 +1124,73 @@ definitions: type: object x-go-name: DomainPermission x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model + domainPermissionSubscription: + properties: + as_draft: + 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. + example: true + type: boolean + x-go-name: AsDraft + content_type: + description: MIME content type to expect at URI. + example: text/csv + type: string + x-go-name: ContentType + count: + description: Count of domain permission entries discovered at URI. + example: 53 + format: uint64 + readOnly: true + type: integer + x-go-name: Count + created_by_account_id: + description: ID of the account that created this subscription. + example: 01FBW21XJA09XYX51KV5JVBW0F + readOnly: true + type: string + x-go-name: CreatedByAccountID + error: + description: 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 + type: string + x-go-name: Error + fetch_password: + description: (Optional) password to set for basic auth when doing a fetch of URI. + example: admin123 + type: string + x-go-name: FetchPassword + fetch_username: + description: (Optional) username to set for basic auth when doing a fetch of URI. + example: admin123 + type: string + x-go-name: FetchUsername + fetched_at: + description: Time at which the most recent fetch was attempted (ISO 8601 Datetime). + example: "2021-07-30T09:20:25+00:00" + readOnly: true + type: string + x-go-name: FetchedAt + id: + description: The ID of the domain permission subscription. + example: 01FBW21XJA09XYX51KV5JVBW0F + readOnly: true + type: string + x-go-name: ID + permission_type: + description: The type of domain permission subscription (allow, block). + example: block + type: string + x-go-name: PermissionType + uri: + description: URI to call in order to fetch the permissions list. + example: https://www.example.org/blocklists/list1.csv + type: string + x-go-name: URI + title: DomainPermissionSubscription represents an auto-refreshing subscription to a list of domain permissions (allows, blocks). + type: object + x-go-name: DomainPermissionSubscription + x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model emoji: properties: category: @@ -5572,6 +5639,249 @@ paths: summary: Force expiry of cached public keys for all accounts on the given domain stored in your database. tags: - admin + /api/v1/admin/domain_permission_drafts: + get: + description: |- + The drafts will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer). + + The next and previous queries can be parsed from the returned Link header. + + Example: + + ``` + ; rel="next", ; rel="prev" + ```` + operationId: domainPermissionDraftsGet + parameters: + - description: Show only drafts created by the given subscription ID. + in: query + name: subscription_id + type: string + - description: Return only drafts that target the given domain. + in: query + name: domain + type: string + - description: Filter on "block" or "allow" type drafts. + in: query + name: permission_type + 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: max_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: since_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: min_id + type: string + - default: 20 + description: Number of items to return. + in: query + maximum: 100 + minimum: 1 + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: Domain permission drafts. + headers: + Link: + description: Links to the next and previous queries. + type: string + schema: + items: + $ref: '#/definitions/domainPermission' + type: array + "400": + description: bad request + "401": + description: unauthorized + "403": + description: forbidden + "404": + description: not found + "406": + description: not acceptable + "500": + description: internal server error + security: + - OAuth2 Bearer: + - admin + summary: View domain permission drafts. + tags: + - admin + post: + consumes: + - multipart/form-data + - application/json + operationId: domainPermissionDraftCreate + parameters: + - description: Domain to create the permission draft for. + in: formData + name: domain + type: string + - description: Create a draft "allow" or a draft "block". + in: query + name: permission_type + type: string + - description: Obfuscate the name of the domain when serving it publicly. Eg., `example.org` becomes something like `ex***e.org`. + in: formData + name: obfuscate + type: boolean + - description: Public comment about this domain permission. This will be displayed alongside the domain permission if you choose to share permissions. + in: formData + name: public_comment + type: string + - description: Private comment about this domain permission. Will only be shown to other admins, so this is a useful way of internally keeping track of why a certain domain ended up permissioned. + in: formData + name: private_comment + type: string + produces: + - application/json + responses: + "200": + description: The newly created domain permission draft. + schema: + $ref: '#/definitions/domainPermission' + "400": + description: bad request + "401": + description: unauthorized + "403": + description: forbidden + "406": + description: not acceptable + "409": + description: conflict + "500": + description: internal server error + security: + - OAuth2 Bearer: + - admin + summary: Create a domain permission draft with the given parameters. + tags: + - admin + /api/v1/admin/domain_permission_drafts/{id}: + get: + operationId: domainPermissionDraftGet + parameters: + - description: ID of the domain permission draft. + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: Domain permission draft. + schema: + $ref: '#/definitions/domainPermission' + "401": + description: unauthorized + "403": + description: forbidden + "404": + description: not found + "406": + description: not acceptable + "500": + description: internal server error + security: + - OAuth2 Bearer: + - admin + summary: Get domain permission draft with the given ID. + tags: + - admin + /api/v1/admin/domain_permission_drafts/{id}/accept: + post: + consumes: + - multipart/form-data + - application/json + operationId: domainPermissionDraftAccept + parameters: + - description: ID of the domain permission draft. + in: path + name: id + required: true + type: string + - default: false + description: If a domain permission already exists with the same domain and permission type as the draft, overwrite the existing permission with fields from the draft. + in: formData + name: overwrite + type: boolean + produces: + - application/json + responses: + "200": + description: The newly created domain permission. + schema: + $ref: '#/definitions/domainPermission' + "400": + description: bad request + "401": + description: unauthorized + "403": + description: forbidden + "406": + description: not acceptable + "409": + description: conflict + "500": + description: internal server error + security: + - OAuth2 Bearer: + - admin + summary: Accept a domain permission draft, turning it into an enforced domain permission. + tags: + - admin + /api/v1/admin/domain_permission_drafts/{id}/remove: + post: + consumes: + - multipart/form-data + - application/json + operationId: domainPermissionDraftRemove + parameters: + - description: ID of the domain permission draft. + in: path + name: id + required: true + type: string + - default: false + description: When removing the domain permission draft, also create a domain ignore entry for the target domain, so that drafts will not be created for this domain in the future. + in: formData + name: ignore_target + type: boolean + produces: + - application/json + responses: + "200": + description: The removed domain permission draft. + schema: + $ref: '#/definitions/domainPermission' + "400": + description: bad request + "401": + description: unauthorized + "403": + description: forbidden + "406": + description: not acceptable + "409": + description: conflict + "500": + description: internal server error + security: + - OAuth2 Bearer: + - admin + summary: Remove a domain permission draft, optionally ignoring all future drafts targeting the given domain. + tags: + - admin /api/v1/admin/email/test: post: consumes: diff --git a/internal/api/client/admin/admin.go b/internal/api/client/admin/admin.go index 2c55de2f0..4b2ee6259 100644 --- a/internal/api/client/admin/admin.go +++ b/internal/api/client/admin/admin.go @@ -28,37 +28,41 @@ ) const ( - BasePath = "/v1/admin" - EmojiPath = BasePath + "/custom_emojis" - EmojiPathWithID = EmojiPath + "/:" + apiutil.IDKey - EmojiCategoriesPath = EmojiPath + "/categories" - DomainBlocksPath = BasePath + "/domain_blocks" - DomainBlocksPathWithID = DomainBlocksPath + "/:" + apiutil.IDKey - DomainAllowsPath = BasePath + "/domain_allows" - DomainAllowsPathWithID = DomainAllowsPath + "/:" + apiutil.IDKey - DomainKeysExpirePath = BasePath + "/domain_keys_expire" - HeaderAllowsPath = BasePath + "/header_allows" - HeaderAllowsPathWithID = HeaderAllowsPath + "/:" + apiutil.IDKey - HeaderBlocksPath = BasePath + "/header_blocks" - HeaderBlocksPathWithID = HeaderBlocksPath + "/:" + apiutil.IDKey - AccountsV1Path = BasePath + "/accounts" - AccountsV2Path = "/v2/admin/accounts" - AccountsPathWithID = AccountsV1Path + "/:" + apiutil.IDKey - AccountsActionPath = AccountsPathWithID + "/action" - AccountsApprovePath = AccountsPathWithID + "/approve" - AccountsRejectPath = AccountsPathWithID + "/reject" - MediaCleanupPath = BasePath + "/media_cleanup" - MediaRefetchPath = BasePath + "/media_refetch" - ReportsPath = BasePath + "/reports" - ReportsPathWithID = ReportsPath + "/:" + apiutil.IDKey - ReportsResolvePath = ReportsPathWithID + "/resolve" - EmailPath = BasePath + "/email" - EmailTestPath = EmailPath + "/test" - InstanceRulesPath = BasePath + "/instance/rules" - InstanceRulesPathWithID = InstanceRulesPath + "/:" + apiutil.IDKey - DebugPath = BasePath + "/debug" - DebugAPUrlPath = DebugPath + "/apurl" - DebugClearCachesPath = DebugPath + "/caches/clear" + BasePath = "/v1/admin" + EmojiPath = BasePath + "/custom_emojis" + EmojiPathWithID = EmojiPath + "/:" + apiutil.IDKey + EmojiCategoriesPath = EmojiPath + "/categories" + DomainBlocksPath = BasePath + "/domain_blocks" + DomainBlocksPathWithID = DomainBlocksPath + "/:" + apiutil.IDKey + DomainAllowsPath = BasePath + "/domain_allows" + DomainAllowsPathWithID = DomainAllowsPath + "/:" + apiutil.IDKey + DomainPermissionDraftsPath = BasePath + "/domain_permission_drafts" + DomainPermissionDraftsPathWithID = DomainPermissionDraftsPath + "/:" + apiutil.IDKey + DomainPermissionDraftAcceptPath = DomainPermissionDraftsPathWithID + "/accept" + DomainPermissionDraftRemovePath = DomainPermissionDraftsPathWithID + "/remove" + DomainKeysExpirePath = BasePath + "/domain_keys_expire" + HeaderAllowsPath = BasePath + "/header_allows" + HeaderAllowsPathWithID = HeaderAllowsPath + "/:" + apiutil.IDKey + HeaderBlocksPath = BasePath + "/header_blocks" + HeaderBlocksPathWithID = HeaderBlocksPath + "/:" + apiutil.IDKey + AccountsV1Path = BasePath + "/accounts" + AccountsV2Path = "/v2/admin/accounts" + AccountsPathWithID = AccountsV1Path + "/:" + apiutil.IDKey + AccountsActionPath = AccountsPathWithID + "/action" + AccountsApprovePath = AccountsPathWithID + "/approve" + AccountsRejectPath = AccountsPathWithID + "/reject" + MediaCleanupPath = BasePath + "/media_cleanup" + MediaRefetchPath = BasePath + "/media_refetch" + ReportsPath = BasePath + "/reports" + ReportsPathWithID = ReportsPath + "/:" + apiutil.IDKey + ReportsResolvePath = ReportsPathWithID + "/resolve" + EmailPath = BasePath + "/email" + EmailTestPath = EmailPath + "/test" + InstanceRulesPath = BasePath + "/instance/rules" + InstanceRulesPathWithID = InstanceRulesPath + "/:" + apiutil.IDKey + DebugPath = BasePath + "/debug" + DebugAPUrlPath = DebugPath + "/apurl" + DebugClearCachesPath = DebugPath + "/caches/clear" FilterQueryKey = "filter" MaxShortcodeDomainKey = "max_shortcode_domain" @@ -99,6 +103,13 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H attachHandler(http.MethodGet, DomainAllowsPathWithID, m.DomainAllowGETHandler) attachHandler(http.MethodDelete, DomainAllowsPathWithID, m.DomainAllowDELETEHandler) + // domain permission draft stuff + attachHandler(http.MethodPost, DomainPermissionDraftsPath, m.DomainPermissionDraftsPOSTHandler) + attachHandler(http.MethodGet, DomainPermissionDraftsPath, m.DomainPermissionDraftsGETHandler) + attachHandler(http.MethodGet, DomainPermissionDraftsPathWithID, m.DomainPermissionDraftGETHandler) + attachHandler(http.MethodPost, DomainPermissionDraftAcceptPath, m.DomainPermissionDraftAcceptPOSTHandler) + attachHandler(http.MethodPost, DomainPermissionDraftRemovePath, m.DomainPermissionDraftRemovePOSTHandler) + // header filtering administration routes attachHandler(http.MethodGet, HeaderAllowsPathWithID, m.HeaderFilterAllowGET) attachHandler(http.MethodGet, HeaderBlocksPathWithID, m.HeaderFilterBlockGET) diff --git a/internal/api/client/admin/domainpermissiondraft.go b/internal/api/client/admin/domainpermissiondraft.go new file mode 100644 index 000000000..aef3b094b --- /dev/null +++ b/internal/api/client/admin/domainpermissiondraft.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 . + +package admin + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// DomainPermissionDraftGETHandler swagger:operation GET /api/v1/admin/domain_permission_drafts/{id} domainPermissionDraftGet +// +// Get domain permission draft with the given ID. +// +// --- +// tags: +// - admin +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// required: true +// in: path +// description: ID of the domain permission draft. +// type: string +// +// security: +// - OAuth2 Bearer: +// - admin +// +// responses: +// '200': +// description: Domain permission draft. +// schema: +// "$ref": "#/definitions/domainPermission" +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) DomainPermissionDraftGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if !*authed.User.Admin { + err := fmt.Errorf("user %s not an admin", authed.User.ID) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if authed.Account.IsMoving() { + apiutil.ForbiddenAfterMove(c) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + permDraft, errWithCode := m.processor.Admin().DomainPermissionDraftGet(c.Request.Context(), id) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, permDraft) +} diff --git a/internal/api/client/admin/domainpermissiondraftaccept.go b/internal/api/client/admin/domainpermissiondraftaccept.go new file mode 100644 index 000000000..5e484cbf3 --- /dev/null +++ b/internal/api/client/admin/domainpermissiondraftaccept.go @@ -0,0 +1,134 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package admin + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// DomainPermissionDraftAcceptPOSTHandler swagger:operation POST /api/v1/admin/domain_permission_drafts/{id}/accept domainPermissionDraftAccept +// +// Accept a domain permission draft, turning it into an enforced domain permission. +// +// --- +// tags: +// - admin +// +// consumes: +// - multipart/form-data +// - application/json +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// required: true +// in: path +// description: ID of the domain permission draft. +// type: string +// - +// name: overwrite +// in: formData +// description: >- +// If a domain permission already exists with the same +// domain and permission type as the draft, overwrite +// the existing permission with fields from the draft. +// type: boolean +// default: false +// +// security: +// - OAuth2 Bearer: +// - admin +// +// responses: +// '200': +// description: The newly created domain permission. +// schema: +// "$ref": "#/definitions/domainPermission" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '406': +// description: not acceptable +// '409': +// description: conflict +// '500': +// description: internal server error +func (m *Module) DomainPermissionDraftAcceptPOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if !*authed.User.Admin { + err := fmt.Errorf("user %s not an admin", authed.User.ID) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if authed.Account.IsMoving() { + apiutil.ForbiddenAfterMove(c) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + type AcceptForm struct { + Overwrite bool `json:"overwrite" form:"overwrite"` + } + + form := new(AcceptForm) + if err := c.ShouldBind(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + return + } + + domainPerm, _, errWithCode := m.processor.Admin().DomainPermissionDraftAccept( + c.Request.Context(), + authed.Account, + id, + form.Overwrite, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, domainPerm) +} diff --git a/internal/api/client/admin/domainpermissiondraftcreate.go b/internal/api/client/admin/domainpermissiondraftcreate.go new file mode 100644 index 000000000..6d5e4ab96 --- /dev/null +++ b/internal/api/client/admin/domainpermissiondraftcreate.go @@ -0,0 +1,176 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package admin + +import ( + "errors" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// DomainPermissionDraftsPOSTHandler swagger:operation POST /api/v1/admin/domain_permission_drafts domainPermissionDraftCreate +// +// Create a domain permission draft with the given parameters. +// +// --- +// tags: +// - admin +// +// consumes: +// - multipart/form-data +// - application/json +// +// produces: +// - application/json +// +// parameters: +// - +// name: domain +// in: formData +// description: Domain to create the permission draft for. +// type: string +// - +// name: permission_type +// type: string +// description: Create a draft "allow" or a draft "block". +// in: query +// - +// name: obfuscate +// in: formData +// description: >- +// Obfuscate the name of the domain when serving it publicly. +// Eg., `example.org` becomes something like `ex***e.org`. +// type: boolean +// - +// name: public_comment +// in: formData +// description: >- +// Public comment about this domain permission. +// This will be displayed alongside the domain permission if you choose to share permissions. +// type: string +// - +// name: private_comment +// in: formData +// description: >- +// Private comment about this domain permission. Will only be shown to other admins, so this +// is a useful way of internally keeping track of why a certain domain ended up permissioned. +// type: string +// +// security: +// - OAuth2 Bearer: +// - admin +// +// responses: +// '200': +// description: The newly created domain permission draft. +// schema: +// "$ref": "#/definitions/domainPermission" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '406': +// description: not acceptable +// '409': +// description: conflict +// '500': +// description: internal server error +func (m *Module) DomainPermissionDraftsPOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if !*authed.User.Admin { + err := fmt.Errorf("user %s not an admin", authed.User.ID) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if authed.Account.IsMoving() { + apiutil.ForbiddenAfterMove(c) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + // Parse + validate form. + form := new(apimodel.DomainPermissionRequest) + if err := c.ShouldBind(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if form.Domain == "" { + const errText = "domain must be set" + errWithCode := gtserror.NewErrorBadRequest(errors.New(errText), errText) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + var ( + permType gtsmodel.DomainPermissionType + errText string + ) + + switch pt := form.PermissionType; pt { + case "block": + permType = gtsmodel.DomainPermissionBlock + case "allow": + permType = gtsmodel.DomainPermissionAllow + case "": + errText = "permission_type not set, must be one of block or allow" + default: + errText = fmt.Sprintf("permission_type %s not recognized, must be one of block or allow", pt) + } + + if errText != "" { + errWithCode := gtserror.NewErrorBadRequest(errors.New(errText), errText) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + permDraft, errWithCode := m.processor.Admin().DomainPermissionDraftCreate( + c.Request.Context(), + authed.Account, + form.Domain, + permType, + form.Obfuscate, + form.PublicComment, + form.PrivateComment, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, permDraft) +} diff --git a/internal/api/client/admin/domainpermissiondraftremove.go b/internal/api/client/admin/domainpermissiondraftremove.go new file mode 100644 index 000000000..1075b3e01 --- /dev/null +++ b/internal/api/client/admin/domainpermissiondraftremove.go @@ -0,0 +1,134 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package admin + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// DomainPermissionDraftRemovePOSTHandler swagger:operation POST /api/v1/admin/domain_permission_drafts/{id}/remove domainPermissionDraftRemove +// +// Remove a domain permission draft, optionally ignoring all future drafts targeting the given domain. +// +// --- +// tags: +// - admin +// +// consumes: +// - multipart/form-data +// - application/json +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// required: true +// in: path +// description: ID of the domain permission draft. +// type: string +// - +// name: ignore_target +// in: formData +// description: >- +// When removing the domain permission draft, also create a +// domain ignore entry for the target domain, so that drafts +// will not be created for this domain in the future. +// type: boolean +// default: false +// +// security: +// - OAuth2 Bearer: +// - admin +// +// responses: +// '200': +// description: The removed domain permission draft. +// schema: +// "$ref": "#/definitions/domainPermission" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '406': +// description: not acceptable +// '409': +// description: conflict +// '500': +// description: internal server error +func (m *Module) DomainPermissionDraftRemovePOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if !*authed.User.Admin { + err := fmt.Errorf("user %s not an admin", authed.User.ID) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if authed.Account.IsMoving() { + apiutil.ForbiddenAfterMove(c) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + type RemoveForm struct { + IgnoreTarget bool `json:"ignore_target" form:"ignore_target"` + } + + form := new(RemoveForm) + if err := c.ShouldBind(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + return + } + + domainPerm, errWithCode := m.processor.Admin().DomainPermissionDraftRemove( + c.Request.Context(), + authed.Account, + id, + form.IgnoreTarget, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, domainPerm) +} diff --git a/internal/api/client/admin/domainpermissiondrafts.go b/internal/api/client/admin/domainpermissiondrafts.go new file mode 100644 index 000000000..dd3315857 --- /dev/null +++ b/internal/api/client/admin/domainpermissiondrafts.go @@ -0,0 +1,189 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package admin + +import ( + "errors" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/paging" +) + +// DomainPermissionDraftsGETHandler swagger:operation GET /api/v1/admin/domain_permission_drafts domainPermissionDraftsGet +// +// View domain permission drafts. +// +// The drafts will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer). +// +// The next and previous queries can be parsed from the returned Link header. +// +// Example: +// +// ``` +// ; rel="next", ; rel="prev" +// ```` +// +// --- +// tags: +// - admin +// +// produces: +// - application/json +// +// parameters: +// - +// name: subscription_id +// type: string +// description: Show only drafts created by the given subscription ID. +// in: query +// - +// name: domain +// type: string +// description: Return only drafts that target the given domain. +// in: query +// - +// name: permission_type +// type: string +// description: Filter on "block" or "allow" type drafts. +// in: query +// - +// name: max_id +// type: string +// description: >- +// Return only items *OLDER* than the given max ID (for paging downwards). +// The item with the specified ID will not be included in the response. +// in: query +// - +// name: since_id +// type: string +// description: >- +// Return only items *NEWER* than the given since ID. +// The item with the specified ID will not be included in the response. +// in: query +// - +// name: min_id +// type: string +// description: >- +// Return only items immediately *NEWER* than the given min ID (for paging upwards). +// The item with the specified ID will not be included in the response. +// in: query +// - +// name: limit +// type: integer +// description: Number of items to return. +// default: 20 +// minimum: 1 +// maximum: 100 +// in: query +// +// security: +// - OAuth2 Bearer: +// - admin +// +// responses: +// '200': +// description: Domain permission drafts. +// schema: +// type: array +// items: +// "$ref": "#/definitions/domainPermission" +// headers: +// Link: +// type: string +// description: Links to the next and previous queries. +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) DomainPermissionDraftsGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if !*authed.User.Admin { + err := fmt.Errorf("user %s not an admin", authed.User.ID) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if authed.Account.IsMoving() { + apiutil.ForbiddenAfterMove(c) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + permType := c.Query(apiutil.DomainPermissionPermTypeKey) + switch permType { + case "", "block", "allow": + // No problem. + + default: + // Invalid. + text := fmt.Sprintf( + "permission_type %s not recognized, valid values are empty string, block, or allow", + permType, + ) + errWithCode := gtserror.NewErrorBadRequest(errors.New(text), text) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + page, errWithCode := paging.ParseIDPage(c, 1, 200, 20) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + resp, errWithCode := m.processor.Admin().DomainPermissionDraftsGet( + c.Request.Context(), + c.Query(apiutil.DomainPermissionSubscriptionIDKey), + c.Query(apiutil.DomainPermissionDomainKey), + gtsmodel.NewDomainPermissionType(permType), + page, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + if resp.LinkHeader != "" { + c.Header("Link", resp.LinkHeader) + } + + apiutil.JSON(c, http.StatusOK, resp.Items) +} diff --git a/internal/api/model/domain.go b/internal/api/model/domain.go index ddc96ef05..55b7166ac 100644 --- a/internal/api/model/domain.go +++ b/internal/api/model/domain.go @@ -61,6 +61,53 @@ type DomainPermission struct { // Time at which the permission entry was created (ISO 8601 Datetime). // example: 2021-07-30T09:20:25+00:00 CreatedAt string `json:"created_at,omitempty"` + // Permission type of this entry (block, allow). + // Only set for domain permission drafts. + PermissionType string `json:"permission_type,omitempty"` +} + +// 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"` + // 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"` + // ID of the account that created this subscription. + // example: 01FBW21XJA09XYX51KV5JVBW0F + // readonly: true + CreatedByAccountID string `json:"created_by_account_id"` + // MIME content type to expect at URI. + // example: text/csv + ContentType string `json:"content_type"` + // URI to call in order to fetch the permissions list. + // example: https://www.example.org/blocklists/list1.csv + URI string `json:"uri"` + // (Optional) username to set for basic auth when doing a fetch of URI. + // example: admin123 + FetchUsername string `json:"fetch_username"` + // (Optional) password to set for basic auth when doing a fetch of URI. + // example: admin123 + FetchPassword string `json:"fetch_password"` + // Time at which the most recent fetch was attempted (ISO 8601 Datetime). + // example: 2021-07-30T09:20:25+00:00 + // readonly: true + FetchedAt string `json:"fetched_at"` + // 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"` + // Count of domain permission entries discovered at URI. + // example: 53 + // readonly: true + Count uint64 `json:"count"` } // DomainPermissionRequest is the form submitted as a POST to create a new domain permission entry (allow/block). @@ -69,22 +116,24 @@ type DomainPermission struct { type DomainPermissionRequest struct { // A list of domains for which this permission request should apply. // Only used if import=true is specified. - Domains *multipart.FileHeader `form:"domains" json:"domains" xml:"domains"` + Domains *multipart.FileHeader `form:"domains" json:"domains"` // A single domain for which this permission request should apply. // Only used if import=true is NOT specified or if import=false. // example: example.org - Domain string `form:"domain" json:"domain" xml:"domain"` + Domain string `form:"domain" json:"domain"` // Obfuscate the domain name when displaying this permission entry publicly. // Ie., instead of 'example.org' show something like 'e**mpl*.or*'. // example: false - Obfuscate bool `form:"obfuscate" json:"obfuscate" xml:"obfuscate"` + Obfuscate bool `form:"obfuscate" json:"obfuscate"` // Private comment for other admins on why this permission entry was created. // example: don't like 'em!!!! - PrivateComment string `form:"private_comment" json:"private_comment" xml:"private_comment"` + PrivateComment string `form:"private_comment" json:"private_comment"` // Public comment on why this permission entry was created. // Will be visible to requesters at /api/v1/instance/peers if this endpoint is exposed. // example: foss dorks 😫 - PublicComment string `form:"public_comment" json:"public_comment" xml:"public_comment"` + PublicComment string `form:"public_comment" json:"public_comment"` + // Permission type to create (only applies to domain permission drafts, not explicit blocks and allows). + PermissionType string `form:"permission_type" json:"permission_type"` } // DomainKeysExpireRequest is the form submitted as a POST to /api/v1/admin/domain_keys_expire to expire a domain's public keys. @@ -92,5 +141,5 @@ type DomainPermissionRequest struct { // swagger:parameters domainKeysExpire type DomainKeysExpireRequest struct { // hostname/domain to expire keys for. - Domain string `form:"domain" json:"domain" xml:"domain"` + Domain string `form:"domain" json:"domain"` } diff --git a/internal/api/util/parsequery.go b/internal/api/util/parsequery.go index 024ec028b..9f4c02aed 100644 --- a/internal/api/util/parsequery.go +++ b/internal/api/util/parsequery.go @@ -69,8 +69,11 @@ /* Domain permission keys */ - DomainPermissionExportKey = "export" - DomainPermissionImportKey = "import" + DomainPermissionExportKey = "export" + DomainPermissionImportKey = "import" + DomainPermissionSubscriptionIDKey = "subscription_id" + DomainPermissionPermTypeKey = "permission_type" + DomainPermissionDomainKey = "domain" /* Admin query keys */ diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 09e505ff5..199e73c80 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -74,6 +74,9 @@ func (c *Caches) Init() { c.initConversationLastStatusIDs() c.initDomainAllow() c.initDomainBlock() + c.initDomainPermissionDraft() + c.initDomainPermissionSubscription() + c.initDomainPermissionIgnore() c.initEmoji() c.initEmojiCategory() c.initFilter() diff --git a/internal/cache/db.go b/internal/cache/db.go index dd4e8b212..f31a19e3b 100644 --- a/internal/cache/db.go +++ b/internal/cache/db.go @@ -67,6 +67,15 @@ type DBCaches struct { // DomainBlock provides access to the domain block database cache. DomainBlock *domain.Cache + // DomainPermissionDraft provides access to the domain permission draft database cache. + DomainPermissionDraft StructCache[*gtsmodel.DomainPermissionDraft] + + // DomainPermissionSubscription provides access to the domain permission subscription database cache. + DomainPermissionSubscription StructCache[*gtsmodel.DomainPermissionSubscription] + + // DomainPermissionIgnore provides access to the domain permission ignore database cache. + DomainPermissionIgnore *domain.Cache + // Emoji provides access to the gtsmodel Emoji database cache. Emoji StructCache[*gtsmodel.Emoji] @@ -548,6 +557,73 @@ func (c *Caches) initDomainBlock() { c.DB.DomainBlock = new(domain.Cache) } +func (c *Caches) initDomainPermissionDraft() { + // Calculate maximum cache size. + cap := calculateResultCacheMax( + sizeofDomainPermissionDraft(), // model in-mem size. + config.GetCacheDomainPermissionDraftMemRation(), + ) + + log.Infof(nil, "cache size = %d", cap) + + copyF := func(d1 *gtsmodel.DomainPermissionDraft) *gtsmodel.DomainPermissionDraft { + d2 := new(gtsmodel.DomainPermissionDraft) + *d2 = *d1 + + // Don't include ptr fields that + // will be populated separately. + d2.CreatedByAccount = nil + + return d2 + } + + c.DB.DomainPermissionDraft.Init(structr.CacheConfig[*gtsmodel.DomainPermissionDraft]{ + Indices: []structr.IndexConfig{ + {Fields: "ID"}, + {Fields: "Domain", Multiple: true}, + {Fields: "SubscriptionID", Multiple: true}, + }, + MaxSize: cap, + IgnoreErr: ignoreErrors, + Copy: copyF, + }) +} + +func (c *Caches) 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) initDomainPermissionIgnore() { + c.DB.DomainPermissionIgnore = new(domain.Cache) +} + func (c *Caches) initEmoji() { // Calculate maximum cache size. cap := calculateResultCacheMax( diff --git a/internal/cache/size.go b/internal/cache/size.go index 8367e4c46..5a7688223 100644 --- a/internal/cache/size.go +++ b/internal/cache/size.go @@ -342,6 +342,35 @@ func sizeofConversation() uintptr { })) } +func sizeofDomainPermissionDraft() uintptr { + return uintptr(size.Of(>smodel.DomainPermissionDraft{ + ID: exampleID, + CreatedAt: exampleTime, + UpdatedAt: exampleTime, + PermissionType: gtsmodel.DomainPermissionBlock, + Domain: "example.org", + CreatedByAccountID: exampleID, + PrivateComment: exampleTextSmall, + PublicComment: exampleTextSmall, + Obfuscate: util.Ptr(false), + SubscriptionID: exampleID, + })) +} + +func sizeofDomainPermissionSubscription() uintptr { + return uintptr(size.Of(>smodel.DomainPermissionSubscription{ + ID: exampleID, + CreatedAt: exampleTime, + PermissionType: gtsmodel.DomainPermissionBlock, + CreatedByAccountID: exampleID, + URI: exampleURI, + FetchUsername: "username", + FetchPassword: "password", + FetchedAt: exampleTime, + AsDraft: util.Ptr(true), + })) +} + func sizeofEmoji() uintptr { return uintptr(size.Of(>smodel.Emoji{ ID: exampleID, diff --git a/internal/config/config.go b/internal/config/config.go index 4a40e9c13..8f48f1d86 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -194,58 +194,60 @@ 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"` - 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"` - 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"` + 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 48d880e1b..827822635 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -158,57 +158,59 @@ // 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, - 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, - 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, + 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 d25d6cca8..abc98b251 100644 --- a/internal/config/helpers.gen.go +++ b/internal/config/helpers.gen.go @@ -3106,6 +3106,68 @@ func SetCacheConversationLastStatusIDsMemRatio(v float64) { global.SetCacheConversationLastStatusIDsMemRatio(v) } +// GetCacheDomainPermissionDraftMemRation safely fetches the Configuration value for state's 'Cache.DomainPermissionDraftMemRation' field +func (st *ConfigState) GetCacheDomainPermissionDraftMemRation() (v float64) { + st.mutex.RLock() + v = st.config.Cache.DomainPermissionDraftMemRation + st.mutex.RUnlock() + return +} + +// SetCacheDomainPermissionDraftMemRation safely sets the Configuration value for state's 'Cache.DomainPermissionDraftMemRation' field +func (st *ConfigState) SetCacheDomainPermissionDraftMemRation(v float64) { + st.mutex.Lock() + defer st.mutex.Unlock() + st.config.Cache.DomainPermissionDraftMemRation = v + st.reloadToViper() +} + +// CacheDomainPermissionDraftMemRationFlag returns the flag name for the 'Cache.DomainPermissionDraftMemRation' field +func CacheDomainPermissionDraftMemRationFlag() string { + return "cache-domain-permission-draft-mem-ratio" +} + +// GetCacheDomainPermissionDraftMemRation safely fetches the value for global configuration 'Cache.DomainPermissionDraftMemRation' field +func GetCacheDomainPermissionDraftMemRation() float64 { + return global.GetCacheDomainPermissionDraftMemRation() +} + +// SetCacheDomainPermissionDraftMemRation safely sets the value for global configuration 'Cache.DomainPermissionDraftMemRation' field +func SetCacheDomainPermissionDraftMemRation(v float64) { + global.SetCacheDomainPermissionDraftMemRation(v) +} + +// 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/domain.go b/internal/db/bundb/domain.go index 0d2a13b34..fd75fec4c 100644 --- a/internal/db/bundb/domain.go +++ b/internal/db/bundb/domain.go @@ -20,6 +20,7 @@ import ( "context" "net/url" + "time" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" @@ -110,6 +111,36 @@ func (d *domainDB) GetDomainAllowByID(ctx context.Context, id string) (*gtsmodel return &allow, nil } +func (d *domainDB) UpdateDomainAllow(ctx context.Context, allow *gtsmodel.DomainAllow, columns ...string) error { + // Normalize the domain as punycode + var err error + allow.Domain, err = util.Punify(allow.Domain) + if err != nil { + return err + } + + // Ensure updated_at is set. + allow.UpdatedAt = time.Now() + if len(columns) != 0 { + columns = append(columns, "updated_at") + } + + // Attempt to update domain allow. + if _, err := d.db. + NewUpdate(). + Model(allow). + Column(columns...). + Where("? = ?", bun.Ident("domain_allow.id"), allow.ID). + Exec(ctx); err != nil { + return err + } + + // Clear the domain allow cache (for later reload) + d.state.Caches.DB.DomainAllow.Clear() + + return nil +} + func (d *domainDB) DeleteDomainAllow(ctx context.Context, domain string) error { // Normalize the domain as punycode domain, err := util.Punify(domain) @@ -206,6 +237,36 @@ func (d *domainDB) GetDomainBlockByID(ctx context.Context, id string) (*gtsmodel return &block, nil } +func (d *domainDB) UpdateDomainBlock(ctx context.Context, block *gtsmodel.DomainBlock, columns ...string) error { + // Normalize the domain as punycode + var err error + block.Domain, err = util.Punify(block.Domain) + if err != nil { + return err + } + + // Ensure updated_at is set. + block.UpdatedAt = time.Now() + if len(columns) != 0 { + columns = append(columns, "updated_at") + } + + // Attempt to update domain block. + if _, err := d.db. + NewUpdate(). + Model(block). + Column(columns...). + Where("? = ?", bun.Ident("domain_block.id"), block.ID). + Exec(ctx); err != nil { + return err + } + + // Clear the domain block cache (for later reload) + d.state.Caches.DB.DomainBlock.Clear() + + return nil +} + func (d *domainDB) DeleteDomainBlock(ctx context.Context, domain string) error { // Normalize the domain as punycode domain, err := util.Punify(domain) diff --git a/internal/db/bundb/domainpermissiondraft.go b/internal/db/bundb/domainpermissiondraft.go new file mode 100644 index 000000000..dec41e9d7 --- /dev/null +++ b/internal/db/bundb/domainpermissiondraft.go @@ -0,0 +1,285 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package bundb + +import ( + "context" + "errors" + "slices" + + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtscontext" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/paging" + "github.com/superseriousbusiness/gotosocial/internal/util" + "github.com/uptrace/bun" +) + +func (d *domainDB) getDomainPermissionDraft( + ctx context.Context, + lookup string, + dbQuery func(*gtsmodel.DomainPermissionDraft) error, + keyParts ...any, +) (*gtsmodel.DomainPermissionDraft, error) { + // Fetch perm draft from database cache with loader callback. + permDraft, err := d.state.Caches.DB.DomainPermissionDraft.LoadOne( + lookup, + // Only called if not cached. + func() (*gtsmodel.DomainPermissionDraft, error) { + var permDraft gtsmodel.DomainPermissionDraft + if err := dbQuery(&permDraft); err != nil { + return nil, err + } + return &permDraft, nil + }, + keyParts..., + ) + if err != nil { + return nil, err + } + + if gtscontext.Barebones(ctx) { + // No need to fully populate. + return permDraft, nil + } + + if permDraft.CreatedByAccount == nil { + // Not set, fetch from database. + permDraft.CreatedByAccount, err = d.state.DB.GetAccountByID( + gtscontext.SetBarebones(ctx), + permDraft.CreatedByAccountID, + ) + if err != nil { + return nil, gtserror.Newf("error populating created by account: %w", err) + } + } + + return permDraft, nil +} + +func (d *domainDB) GetDomainPermissionDraftByID( + ctx context.Context, + id string, +) (*gtsmodel.DomainPermissionDraft, error) { + return d.getDomainPermissionDraft( + ctx, + "ID", + func(permDraft *gtsmodel.DomainPermissionDraft) error { + return d.db. + NewSelect(). + Model(permDraft). + Where("? = ?", bun.Ident("domain_permission_draft.id"), id). + Scan(ctx) + }, + id, + ) +} + +func (d *domainDB) GetDomainPermissionDrafts( + ctx context.Context, + permType gtsmodel.DomainPermissionType, + permSubID string, + domain string, + page *paging.Page, +) ( + []*gtsmodel.DomainPermissionDraft, + error, +) { + var ( + // Get paging params. + minID = page.GetMin() + maxID = page.GetMax() + limit = page.GetLimit() + order = page.GetOrder() + + // Make educated guess for slice size + permDraftIDs = make([]string, 0, limit) + ) + + q := d.db. + NewSelect(). + TableExpr( + "? AS ?", + bun.Ident("domain_permission_drafts"), + bun.Ident("domain_permission_draft"), + ). + // Select only IDs from table + Column("domain_permission_draft.id") + + // Return only items with id + // lower than provided maxID. + if maxID != "" { + q = q.Where( + "? < ?", + bun.Ident("domain_permission_draft.id"), + maxID, + ) + } + + // Return only items with id + // greater than provided minID. + if minID != "" { + q = q.Where( + "? > ?", + bun.Ident("domain_permission_draft.id"), + minID, + ) + } + + // Return only items with + // given permission type. + if permType != gtsmodel.DomainPermissionUnknown { + q = q.Where( + "? = ?", + bun.Ident("domain_permission_draft.permission_type"), + permType, + ) + } + + // Return only items with + // given subscription ID. + if permSubID != "" { + q = q.Where( + "? = ?", + bun.Ident("domain_permission_draft.subscription_id"), + permSubID, + ) + } + + // Return only items + // with given domain. + if domain != "" { + var err error + + // Normalize domain as punycode. + domain, err = util.Punify(domain) + if err != nil { + return nil, gtserror.Newf("error punifying domain %s: %w", domain, err) + } + + q = q.Where( + "? = ?", + bun.Ident("domain_permission_draft.domain"), + domain, + ) + } + + if limit > 0 { + // Limit amount of + // items returned. + q = q.Limit(limit) + } + + if order == paging.OrderAscending { + // Page up. + q = q.OrderExpr( + "? ASC", + bun.Ident("domain_permission_draft.id"), + ) + } else { + // Page down. + q = q.OrderExpr( + "? DESC", + bun.Ident("domain_permission_draft.id"), + ) + } + + if err := q.Scan(ctx, &permDraftIDs); err != nil { + return nil, err + } + + // Catch case of no items early + if len(permDraftIDs) == 0 { + return nil, db.ErrNoEntries + } + + // If we're paging up, we still want items + // to be sorted by ID desc, so reverse slice. + if order == paging.OrderAscending { + slices.Reverse(permDraftIDs) + } + + // Allocate return slice (will be at most len permDraftIDs) + permDrafts := make([]*gtsmodel.DomainPermissionDraft, 0, len(permDraftIDs)) + for _, id := range permDraftIDs { + permDraft, err := d.GetDomainPermissionDraftByID(ctx, id) + if err != nil { + log.Errorf(ctx, "error getting domain permission draft %q: %v", id, err) + continue + } + + // Append to return slice + permDrafts = append(permDrafts, permDraft) + } + + return permDrafts, nil +} + +func (d *domainDB) PutDomainPermissionDraft( + ctx context.Context, + permDraft *gtsmodel.DomainPermissionDraft, +) error { + var err error + + // Normalize the domain as punycode + permDraft.Domain, err = util.Punify(permDraft.Domain) + if err != nil { + return gtserror.Newf("error punifying domain %s: %w", permDraft.Domain, err) + } + + return d.state.Caches.DB.DomainPermissionDraft.Store( + permDraft, + func() error { + _, err := d.db. + NewInsert(). + Model(permDraft). + Exec(ctx) + return err + }, + ) +} + +func (d *domainDB) DeleteDomainPermissionDraft( + ctx context.Context, + id string, +) error { + // Delete the permDraft from DB. + q := d.db.NewDelete(). + TableExpr( + "? AS ?", + bun.Ident("domain_permission_drafts"), + bun.Ident("domain_permission_draft"), + ). + Where( + "? = ?", + bun.Ident("domain_permission_draft.id"), + id, + ) + + _, err := q.Exec(ctx) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return err + } + + // Invalidate any cached model by ID. + d.state.Caches.DB.DomainPermissionDraft.Invalidate("ID", id) + + return nil +} diff --git a/internal/db/bundb/domainpermissionignore.go b/internal/db/bundb/domainpermissionignore.go new file mode 100644 index 000000000..ca2958a4b --- /dev/null +++ b/internal/db/bundb/domainpermissionignore.go @@ -0,0 +1,275 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package bundb + +import ( + "context" + "errors" + "slices" + + "github.com/miekg/dns" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtscontext" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/paging" + "github.com/superseriousbusiness/gotosocial/internal/util" + "github.com/uptrace/bun" +) + +func (d *domainDB) PutDomainPermissionIgnore( + ctx context.Context, + ignore *gtsmodel.DomainPermissionIgnore, +) error { + // Normalize the domain as punycode + var err error + ignore.Domain, err = util.Punify(ignore.Domain) + if err != nil { + return err + } + + // Attempt to store domain perm ignore in DB + if _, err := d.db.NewInsert(). + Model(ignore). + Exec(ctx); err != nil { + return err + } + + // Clear the domain perm ignore cache (for later reload) + d.state.Caches.DB.DomainPermissionIgnore.Clear() + + return nil +} + +func (d *domainDB) IsDomainPermissionIgnored(ctx context.Context, domain string) (bool, error) { + // Normalize the domain as punycode + domain, err := util.Punify(domain) + if err != nil { + return false, err + } + + // Check if our host and given domain are equal + // or part of the same second-level domain; we + // always ignore such perms as creating blocks + // or allows in such cases may break things. + if dns.CompareDomainName(domain, config.GetHost()) >= 2 { + return true, nil + } + + // Func to scan list of all + // ignored domain perms from DB. + loadF := func() ([]string, error) { + var domains []string + + if err := d.db. + NewSelect(). + Table("domain_ignores"). + Column("domain"). + Scan(ctx, &domains); err != nil { + return nil, err + } + + return domains, nil + } + + // Check the cache for a domain perm ignore, + // hydrating the cache with loadF if necessary. + return d.state.Caches.DB.DomainPermissionIgnore.Matches(domain, loadF) +} + +func (d *domainDB) GetDomainPermissionIgnoreByID( + ctx context.Context, + id string, +) (*gtsmodel.DomainPermissionIgnore, error) { + ignore := new(gtsmodel.DomainPermissionIgnore) + + q := d.db. + NewSelect(). + Model(ignore). + Where("? = ?", bun.Ident("domain_permission_ignore.id"), id) + if err := q.Scan(ctx); err != nil { + return nil, err + } + + if gtscontext.Barebones(ctx) { + // No need to fully populate. + return ignore, nil + } + + if ignore.CreatedByAccount == nil { + // Not set, fetch from database. + var err error + ignore.CreatedByAccount, err = d.state.DB.GetAccountByID( + gtscontext.SetBarebones(ctx), + ignore.CreatedByAccountID, + ) + if err != nil { + return nil, gtserror.Newf("error populating created by account: %w", err) + } + } + + return ignore, nil +} + +func (d *domainDB) GetDomainPermissionIgnores( + ctx context.Context, + domain string, + page *paging.Page, +) ( + []*gtsmodel.DomainPermissionIgnore, + error, +) { + var ( + // Get paging params. + minID = page.GetMin() + maxID = page.GetMax() + limit = page.GetLimit() + order = page.GetOrder() + + // Make educated guess for slice size + ignoreIDs = make([]string, 0, limit) + ) + + q := d.db. + NewSelect(). + TableExpr( + "? AS ?", + bun.Ident("domain_permission_ignores"), + bun.Ident("domain_permission_ignore"), + ). + // Select only IDs from table + Column("domain_permission_ignore.id") + + // Return only items with id + // lower than provided maxID. + if maxID != "" { + q = q.Where( + "? < ?", + bun.Ident("domain_permission_ignore.id"), + maxID, + ) + } + + // Return only items with id + // greater than provided minID. + if minID != "" { + q = q.Where( + "? > ?", + bun.Ident("domain_permission_ignore.id"), + minID, + ) + } + + // Return only items + // with given domain. + if domain != "" { + var err error + + // Normalize domain as punycode. + domain, err = util.Punify(domain) + if err != nil { + return nil, gtserror.Newf("error punifying domain %s: %w", domain, err) + } + + q = q.Where( + "? = ?", + bun.Ident("domain_permission_ignore.domain"), + domain, + ) + } + + if limit > 0 { + // Limit amount of + // items returned. + q = q.Limit(limit) + } + + if order == paging.OrderAscending { + // Page up. + q = q.OrderExpr( + "? ASC", + bun.Ident("domain_permission_ignore.id"), + ) + } else { + // Page down. + q = q.OrderExpr( + "? DESC", + bun.Ident("domain_permission_ignore.id"), + ) + } + + if err := q.Scan(ctx, &ignoreIDs); err != nil { + return nil, err + } + + // Catch case of no items early + if len(ignoreIDs) == 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(ignoreIDs) + } + + // Allocate return slice (will be at most len permSubIDs). + ignores := make([]*gtsmodel.DomainPermissionIgnore, 0, len(ignoreIDs)) + for _, id := range ignoreIDs { + ignore, err := d.GetDomainPermissionIgnoreByID(ctx, id) + if err != nil { + log.Errorf(ctx, "error getting domain permission ignore %q: %v", id, err) + continue + } + + // Append to return slice + ignores = append(ignores, ignore) + } + + return ignores, nil +} + +func (d *domainDB) DeleteDomainPermissionIgnore( + ctx context.Context, + id string, +) error { + // Delete the permSub from DB. + q := d.db.NewDelete(). + TableExpr( + "? AS ?", + bun.Ident("domain_permission_ignores"), + bun.Ident("domain_permission_ignore"), + ). + Where( + "? = ?", + bun.Ident("domain_permission_ignore.id"), + id, + ) + + _, err := q.Exec(ctx) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return err + } + + // Clear the domain perm ignore cache (for later reload) + d.state.Caches.DB.DomainPermissionIgnore.Clear() + + return nil +} diff --git a/internal/db/bundb/domainpermissionsubscription.go b/internal/db/bundb/domainpermissionsubscription.go new file mode 100644 index 000000000..0e4a95abf --- /dev/null +++ b/internal/db/bundb/domainpermissionsubscription.go @@ -0,0 +1,246 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package 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) 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) 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 +} 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..3a7db3dc6 --- /dev/null +++ b/internal/db/bundb/migrations/20241022153016_domain_permission_subscriptions.go @@ -0,0 +1,94 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package migrations + +import ( + "context" + + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/uptrace/bun" +) + +func init() { + up := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + + // Create `domain_permission_drafts`. + if _, err := tx. + NewCreateTable(). + Model((*gtsmodel.DomainPermissionDraft)(nil)). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + + // Create `domain_permission_subscriptions`. + if _, err := tx. + NewCreateTable(). + Model((*gtsmodel.DomainPermissionSubscription)(nil)). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + + // Create `domain_permission_ignores`. + if _, err := tx. + NewCreateTable(). + Model((*gtsmodel.DomainPermissionIgnore)(nil)). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + + // Create indexes. Indices. Indie sexes. + for table, indexes := range map[string]map[string][]string{ + "domain_permission_drafts": { + "domain_permission_drafts_domain_idx": {"domain"}, + "domain_permission_drafts_subscription_id_idx": {"subscription_id"}, + }, + "domain_permission_subscriptions": { + "domain_permission_subscriptions_permission_type_idx": {"permission_type"}, + }, + } { + for index, columns := range indexes { + if _, err := tx. + NewCreateIndex(). + Table(table). + Index(index). + Column(columns...). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + } + } + + return nil + }) + } + + down := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + return nil + }) + } + + if err := Migrations.Register(up, down); err != nil { + panic(err) + } +} diff --git a/internal/db/domain.go b/internal/db/domain.go index 3f7803d62..dc70e8cef 100644 --- a/internal/db/domain.go +++ b/internal/db/domain.go @@ -22,6 +22,7 @@ "net/url" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/paging" ) // Domain contains DB functions related to domains and domain blocks. @@ -42,6 +43,9 @@ type Domain interface { // GetDomainAllows returns all instance-level domain allows currently enforced by this instance. GetDomainAllows(ctx context.Context) ([]*gtsmodel.DomainAllow, error) + // UpdateDomainAllow updates the given domain allow, setting the provided columns (empty for all). + UpdateDomainAllow(ctx context.Context, allow *gtsmodel.DomainAllow, columns ...string) error + // DeleteDomainAllow deletes an instance-level domain allow with the given domain, if it exists. DeleteDomainAllow(ctx context.Context, domain string) error @@ -57,6 +61,9 @@ type Domain interface { // GetDomainBlocks returns all instance-level domain blocks currently enforced by this instance. GetDomainBlocks(ctx context.Context) ([]*gtsmodel.DomainBlock, error) + // UpdateDomainBlock updates the given domain block, setting the provided columns (empty for all). + UpdateDomainBlock(ctx context.Context, block *gtsmodel.DomainBlock, columns ...string) error + // DeleteDomainBlock deletes an instance-level domain block with the given domain, if it exists. DeleteDomainBlock(ctx context.Context, domain string) error @@ -78,4 +85,69 @@ type Domain interface { // AreURIsBlocked calls IsURIBlocked for each URI. // Will return true if even one of the given URIs is blocked. AreURIsBlocked(ctx context.Context, uris []*url.URL) (bool, error) + + /* + Domain permission draft stuff. + */ + + // GetDomainPermissionDraftByID gets one DomainPermissionDraft with the given ID. + GetDomainPermissionDraftByID(ctx context.Context, id string) (*gtsmodel.DomainPermissionDraft, error) + + // GetDomainPermissionDrafts returns a page of + // DomainPermissionDrafts using the given parameters. + GetDomainPermissionDrafts( + ctx context.Context, + permType gtsmodel.DomainPermissionType, + permSubID string, + domain string, + page *paging.Page, + ) ([]*gtsmodel.DomainPermissionDraft, error) + + // PutDomainPermissionDraft stores one DomainPermissionDraft. + PutDomainPermissionDraft(ctx context.Context, permDraft *gtsmodel.DomainPermissionDraft) error + + // DeleteDomainPermissionDraft deletes one DomainPermissionDraft with the given id. + DeleteDomainPermissionDraft(ctx context.Context, id string) error + + /* + Domain permission ignore stuff. + */ + + // GetDomainPermissionIgnoreByID gets one DomainPermissionIgnore with the given ID. + GetDomainPermissionIgnoreByID(ctx context.Context, id string) (*gtsmodel.DomainPermissionIgnore, error) + + // GetDomainPermissionIgnores returns a page of + // DomainPermissionIgnores using the given parameters. + GetDomainPermissionIgnores( + ctx context.Context, + domain string, + page *paging.Page, + ) ([]*gtsmodel.DomainPermissionIgnore, error) + + // PutDomainPermissionIgnore stores one DomainPermissionIgnore. + PutDomainPermissionIgnore(ctx context.Context, permIgnore *gtsmodel.DomainPermissionIgnore) error + + // DeleteDomainPermissionIgnore deletes one DomainPermissionIgnore with the given id. + DeleteDomainPermissionIgnore(ctx context.Context, id string) 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) + + // PutDomainPermissionSubscription stores one DomainPermissionSubscription. + PutDomainPermissionSubscription(ctx context.Context, permSub *gtsmodel.DomainPermissionSubscription) error + + // DeleteDomainPermissionSubscription deletes one DomainPermissionSubscription with the given id. + DeleteDomainPermissionSubscription(ctx context.Context, id string) error } diff --git a/internal/gtsmodel/domainallow.go b/internal/gtsmodel/domainallow.go index 2a3e53e79..7b947333b 100644 --- a/internal/gtsmodel/domainallow.go +++ b/internal/gtsmodel/domainallow.go @@ -45,6 +45,10 @@ func (d *DomainAllow) GetUpdatedAt() time.Time { return d.UpdatedAt } +func (d *DomainAllow) SetUpdatedAt(i time.Time) { + d.UpdatedAt = i +} + func (d *DomainAllow) GetDomain() string { return d.Domain } @@ -53,26 +57,50 @@ func (d *DomainAllow) GetCreatedByAccountID() string { return d.CreatedByAccountID } +func (d *DomainAllow) SetCreatedByAccountID(i string) { + d.CreatedByAccountID = i +} + func (d *DomainAllow) GetCreatedByAccount() *Account { return d.CreatedByAccount } +func (d *DomainAllow) SetCreatedByAccount(i *Account) { + d.CreatedByAccount = i +} + func (d *DomainAllow) GetPrivateComment() string { return d.PrivateComment } +func (d *DomainAllow) SetPrivateComment(i string) { + d.PrivateComment = i +} + func (d *DomainAllow) GetPublicComment() string { return d.PublicComment } +func (d *DomainAllow) SetPublicComment(i string) { + d.PublicComment = i +} + func (d *DomainAllow) GetObfuscate() *bool { return d.Obfuscate } +func (d *DomainAllow) SetObfuscate(i *bool) { + d.Obfuscate = i +} + func (d *DomainAllow) GetSubscriptionID() string { return d.SubscriptionID } +func (d *DomainAllow) SetSubscriptionID(i string) { + d.SubscriptionID = i +} + func (d *DomainAllow) GetType() DomainPermissionType { return DomainPermissionAllow } diff --git a/internal/gtsmodel/domainblock.go b/internal/gtsmodel/domainblock.go index 4e0b3ca65..e99fea301 100644 --- a/internal/gtsmodel/domainblock.go +++ b/internal/gtsmodel/domainblock.go @@ -45,6 +45,10 @@ func (d *DomainBlock) GetUpdatedAt() time.Time { return d.UpdatedAt } +func (d *DomainBlock) SetUpdatedAt(i time.Time) { + d.UpdatedAt = i +} + func (d *DomainBlock) GetDomain() string { return d.Domain } @@ -53,26 +57,50 @@ func (d *DomainBlock) GetCreatedByAccountID() string { return d.CreatedByAccountID } +func (d *DomainBlock) SetCreatedByAccountID(i string) { + d.CreatedByAccountID = i +} + func (d *DomainBlock) GetCreatedByAccount() *Account { return d.CreatedByAccount } +func (d *DomainBlock) SetCreatedByAccount(i *Account) { + d.CreatedByAccount = i +} + func (d *DomainBlock) GetPrivateComment() string { return d.PrivateComment } +func (d *DomainBlock) SetPrivateComment(i string) { + d.PrivateComment = i +} + func (d *DomainBlock) GetPublicComment() string { return d.PublicComment } +func (d *DomainBlock) SetPublicComment(i string) { + d.PublicComment = i +} + func (d *DomainBlock) GetObfuscate() *bool { return d.Obfuscate } +func (d *DomainBlock) SetObfuscate(i *bool) { + d.Obfuscate = i +} + func (d *DomainBlock) GetSubscriptionID() string { return d.SubscriptionID } +func (d *DomainBlock) SetSubscriptionID(i string) { + d.SubscriptionID = i +} + func (d *DomainBlock) GetType() DomainPermissionType { return DomainPermissionBlock } diff --git a/internal/gtsmodel/domainpermission.go b/internal/gtsmodel/domainpermission.go index 01e8fdaaa..668ea88b7 100644 --- a/internal/gtsmodel/domainpermission.go +++ b/internal/gtsmodel/domainpermission.go @@ -19,19 +19,26 @@ import "time" -// DomainPermission models a domain -// permission entry (block/allow). +// DomainPermission models a domain permission +// entry -- block / allow / draft / ignore. type DomainPermission interface { GetID() string GetCreatedAt() time.Time GetUpdatedAt() time.Time + SetUpdatedAt(i time.Time) GetDomain() string GetCreatedByAccountID() string + SetCreatedByAccountID(i string) GetCreatedByAccount() *Account + SetCreatedByAccount(i *Account) GetPrivateComment() string + SetPrivateComment(i string) GetPublicComment() string + SetPublicComment(i string) GetObfuscate() *bool + SetObfuscate(i *bool) GetSubscriptionID() string + SetSubscriptionID(i string) GetType() DomainPermissionType } diff --git a/internal/gtsmodel/domainpermissiondraft.go b/internal/gtsmodel/domainpermissiondraft.go new file mode 100644 index 000000000..6715e603c --- /dev/null +++ b/internal/gtsmodel/domainpermissiondraft.go @@ -0,0 +1,106 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package gtsmodel + +import "time" + +type DomainPermissionDraft struct { + ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // ID of this item in the database. + CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // Time when this item was created. + UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // Time when this item was last updated. + PermissionType DomainPermissionType `bun:",notnull,unique:domain_permission_drafts_permission_type_domain_subscription_id_uniq"` // Permission type of the draft. + Domain string `bun:",nullzero,notnull,unique:domain_permission_drafts_permission_type_domain_subscription_id_uniq"` // Domain to block or allow. Eg. 'whatever.com'. + CreatedByAccountID string `bun:"type:CHAR(26),nullzero,notnull"` // Account ID of the creator of this subscription. + CreatedByAccount *Account `bun:"-"` // Account corresponding to createdByAccountID. + PrivateComment string `bun:",nullzero"` // Private comment on this perm, viewable to admins. + PublicComment string `bun:",nullzero"` // Public comment on this perm, viewable (optionally) by everyone. + Obfuscate *bool `bun:",nullzero,notnull,default:false"` // Obfuscate domain name when displaying it publicly. + SubscriptionID string `bun:"type:CHAR(26),nullzero,unique:domain_permission_drafts_permission_type_domain_subscription_id_uniq"` // ID of the subscription that created this draft, if any. +} + +func (d *DomainPermissionDraft) GetID() string { + return d.ID +} + +func (d *DomainPermissionDraft) GetCreatedAt() time.Time { + return d.CreatedAt +} + +func (d *DomainPermissionDraft) GetUpdatedAt() time.Time { + return d.UpdatedAt +} + +func (d *DomainPermissionDraft) SetUpdatedAt(i time.Time) { + d.UpdatedAt = i +} + +func (d *DomainPermissionDraft) GetDomain() string { + return d.Domain +} + +func (d *DomainPermissionDraft) GetCreatedByAccountID() string { + return d.CreatedByAccountID +} + +func (d *DomainPermissionDraft) SetCreatedByAccountID(i string) { + d.CreatedByAccountID = i +} + +func (d *DomainPermissionDraft) GetCreatedByAccount() *Account { + return d.CreatedByAccount +} + +func (d *DomainPermissionDraft) SetCreatedByAccount(i *Account) { + d.CreatedByAccount = i +} + +func (d *DomainPermissionDraft) GetPrivateComment() string { + return d.PrivateComment +} + +func (d *DomainPermissionDraft) SetPrivateComment(i string) { + d.PrivateComment = i +} + +func (d *DomainPermissionDraft) GetPublicComment() string { + return d.PublicComment +} + +func (d *DomainPermissionDraft) SetPublicComment(i string) { + d.PublicComment = i +} + +func (d *DomainPermissionDraft) GetObfuscate() *bool { + return d.Obfuscate +} + +func (d *DomainPermissionDraft) SetObfuscate(i *bool) { + d.Obfuscate = i +} + +func (d *DomainPermissionDraft) GetSubscriptionID() string { + return d.SubscriptionID +} + +func (d *DomainPermissionDraft) SetSubscriptionID(i string) { + d.SubscriptionID = i +} + +func (d *DomainPermissionDraft) GetType() DomainPermissionType { + return d.PermissionType +} diff --git a/internal/gtsmodel/domainpermissionignore.go b/internal/gtsmodel/domainpermissionignore.go new file mode 100644 index 000000000..68be9f3c9 --- /dev/null +++ b/internal/gtsmodel/domainpermissionignore.go @@ -0,0 +1,92 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package gtsmodel + +import ( + "time" + + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +// DomainPermissionIgnore represents one domain that should be ignored +// when domain permission (ignores) are created from subscriptions. +type DomainPermissionIgnore struct { + ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // ID of this item in the database. + CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // Time when this item was created. + UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // Time when this item was last updated. + Domain string `bun:",nullzero,notnull,unique"` // Domain to ignore. Eg. 'whatever.com'. + CreatedByAccountID string `bun:"type:CHAR(26),nullzero,notnull"` // Account ID of the creator of this ignore. + CreatedByAccount *Account `bun:"-"` // Account corresponding to createdByAccountID. + PrivateComment string `bun:",nullzero"` // Private comment on this ignore, viewable to admins. +} + +func (d *DomainPermissionIgnore) GetID() string { + return d.ID +} + +func (d *DomainPermissionIgnore) GetCreatedAt() time.Time { + return d.CreatedAt +} + +func (d *DomainPermissionIgnore) GetUpdatedAt() time.Time { + return d.UpdatedAt +} + +func (d *DomainPermissionIgnore) SetUpdatedAt(i time.Time) { + d.UpdatedAt = i +} + +func (d *DomainPermissionIgnore) GetDomain() string { + return d.Domain +} + +func (d *DomainPermissionIgnore) GetCreatedByAccountID() string { + return d.CreatedByAccountID +} + +func (d *DomainPermissionIgnore) SetCreatedByAccountID(i string) { + d.CreatedByAccountID = i +} + +func (d *DomainPermissionIgnore) GetCreatedByAccount() *Account { + return d.CreatedByAccount +} + +func (d *DomainPermissionIgnore) SetCreatedByAccount(i *Account) { + d.CreatedByAccount = i +} + +func (d *DomainPermissionIgnore) GetPrivateComment() string { + return d.PrivateComment +} + +func (d *DomainPermissionIgnore) SetPrivateComment(i string) { + d.PrivateComment = i +} + +/* + Stubbed functions for interface purposes. +*/ + +func (d *DomainPermissionIgnore) GetPublicComment() string { return "" } +func (d *DomainPermissionIgnore) SetPublicComment(_ string) {} +func (d *DomainPermissionIgnore) GetObfuscate() *bool { return util.Ptr(false) } +func (d *DomainPermissionIgnore) SetObfuscate(_ *bool) {} +func (d *DomainPermissionIgnore) GetSubscriptionID() string { return "" } +func (d *DomainPermissionIgnore) SetSubscriptionID(_ string) {} +func (d *DomainPermissionIgnore) GetType() DomainPermissionType { return DomainPermissionUnknown } diff --git a/internal/gtsmodel/domainpermissionsubscription.go b/internal/gtsmodel/domainpermissionsubscription.go new file mode 100644 index 000000000..813d906da --- /dev/null +++ b/internal/gtsmodel/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 . + +package gtsmodel + +import "time" + +type DomainPermissionSubscription struct { + ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // ID of this item in the database. + CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // Time when this item was created. + Title string `bun:",nullzero"` // Moderator-set title for this list. + PermissionType DomainPermissionType `bun:",notnull"` // Permission type of the subscription. + AsDraft *bool `bun:",nullzero,notnull,default:true"` // Create domain permission entries resulting from this subscription as drafts. + CreatedByAccountID string `bun:"type:CHAR(26),nullzero,notnull"` // Account ID of the creator of this subscription. + CreatedByAccount *Account `bun:"-"` // Account corresponding to createdByAccountID. + ContentType string `bun:",nullzero,notnull"` // Content type to expect from the URI. + URI string `bun:",unique,nullzero,notnull"` // URI of the domain permission list. + 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. + IsError *bool `bun:",nullzero,notnull,default:false"` // True if last fetch attempt of URI resulted in an error. + Error string `bun:",nullzero"` // If IsError=true, this field contains the error resulting from the attempted fetch. + Count uint64 `bun:""` // Count of domain permission entries discovered at URI. +} diff --git a/internal/processing/admin/domainpermission.go b/internal/processing/admin/domainpermission.go index bedaf6a11..55800f458 100644 --- a/internal/processing/admin/domainpermission.go +++ b/internal/processing/admin/domainpermission.go @@ -31,24 +31,6 @@ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) -// apiDomainPerm is a cheeky shortcut for returning -// the API version of the given domain permission -// (*gtsmodel.DomainBlock or *gtsmodel.DomainAllow), -// or an appropriate error if something goes wrong. -func (p *Processor) apiDomainPerm( - ctx context.Context, - domainPermission gtsmodel.DomainPermission, - export bool, -) (*apimodel.DomainPermission, gtserror.WithCode) { - apiDomainPerm, err := p.converter.DomainPermToAPIDomainPerm(ctx, domainPermission, export) - if err != nil { - err := gtserror.NewfAt(3, "error converting domain permission to api model: %w", err) - return nil, gtserror.NewErrorInternalError(err) - } - - return apiDomainPerm, nil -} - // DomainPermissionCreate creates an instance-level permission // targeting the given domain, and then processes any side // effects of the permission creation. diff --git a/internal/processing/admin/domainpermissiondraft.go b/internal/processing/admin/domainpermissiondraft.go new file mode 100644 index 000000000..210417d2f --- /dev/null +++ b/internal/processing/admin/domainpermissiondraft.go @@ -0,0 +1,325 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package admin + +import ( + "context" + "errors" + "fmt" + "net/url" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtscontext" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/paging" +) + +// DomainPermissionDraftGet returns one +// domain permission draft with the given id. +func (p *Processor) DomainPermissionDraftGet( + ctx context.Context, + id string, +) (*apimodel.DomainPermission, gtserror.WithCode) { + permDraft, err := p.state.DB.GetDomainPermissionDraftByID(ctx, id) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting domain permission draft %s: %w", id, err) + return nil, gtserror.NewErrorInternalError(err) + } + + if permDraft == nil { + err := fmt.Errorf("domain permission draft %s not found", id) + return nil, gtserror.NewErrorNotFound(err, err.Error()) + } + + return p.apiDomainPerm(ctx, permDraft, false) +} + +// DomainPermissionDraftsGet returns a page of +// DomainPermissionDrafts with the given parameters. +func (p *Processor) DomainPermissionDraftsGet( + ctx context.Context, + subscriptionID string, + domain string, + permType gtsmodel.DomainPermissionType, + page *paging.Page, +) (*apimodel.PageableResponse, gtserror.WithCode) { + permDrafts, err := p.state.DB.GetDomainPermissionDrafts( + ctx, + permType, + subscriptionID, + domain, + page, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + count := len(permDrafts) + if count == 0 { + return paging.EmptyResponse(), nil + } + + // Get the lowest and highest + // ID values, used for paging. + lo := permDrafts[count-1].ID + hi := permDrafts[0].ID + + // Convert each perm draft to API model. + items := make([]any, len(permDrafts)) + for i, permDraft := range permDrafts { + apiPermDraft, err := p.apiDomainPerm(ctx, permDraft, false) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + items[i] = apiPermDraft + } + + // Assemble next/prev page queries. + query := make(url.Values, 3) + if subscriptionID != "" { + query.Set(apiutil.DomainPermissionSubscriptionIDKey, subscriptionID) + } + if domain != "" { + query.Set(apiutil.DomainPermissionDomainKey, domain) + } + if permType != gtsmodel.DomainPermissionUnknown { + query.Set(apiutil.DomainPermissionPermTypeKey, permType.String()) + } + + return paging.PackageResponse(paging.ResponseParams{ + Items: items, + Path: "/api/v1/admin/domain_permission_drafts", + Next: page.Next(lo, hi), + Prev: page.Prev(lo, hi), + Query: query, + }), nil +} + +func (p *Processor) DomainPermissionDraftCreate( + ctx context.Context, + acct *gtsmodel.Account, + domain string, + permType gtsmodel.DomainPermissionType, + obfuscate bool, + publicComment string, + privateComment string, +) (*apimodel.DomainPermission, gtserror.WithCode) { + permDraft := >smodel.DomainPermissionDraft{ + ID: id.NewULID(), + PermissionType: permType, + Domain: domain, + CreatedByAccountID: acct.ID, + CreatedByAccount: acct, + PrivateComment: privateComment, + PublicComment: publicComment, + Obfuscate: &obfuscate, + } + + if err := p.state.DB.PutDomainPermissionDraft(ctx, permDraft); err != nil { + if errors.Is(err, db.ErrAlreadyExists) { + const text = "a domain permission draft already exists with this permission type, domain, and subscription ID" + err := fmt.Errorf("%w: %s", err, text) + return nil, gtserror.NewErrorConflict(err, text) + } + + // Real error. + err := gtserror.Newf("db error putting domain permission draft: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return p.apiDomainPerm(ctx, permDraft, false) +} + +func (p *Processor) DomainPermissionDraftAccept( + ctx context.Context, + acct *gtsmodel.Account, + id string, + overwrite bool, +) (*apimodel.DomainPermission, string, gtserror.WithCode) { + permDraft, err := p.state.DB.GetDomainPermissionDraftByID(ctx, id) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting domain permission draft %s: %w", id, err) + return nil, "", gtserror.NewErrorInternalError(err) + } + + if permDraft == nil { + err := fmt.Errorf("domain permission draft %s not found", id) + return nil, "", gtserror.NewErrorNotFound(err, err.Error()) + } + + var ( + // Existing permission + // entry, if it exists. + existing gtsmodel.DomainPermission + ) + + // Check if existing entry. + switch permDraft.PermissionType { + case gtsmodel.DomainPermissionBlock: + existing, err = p.state.DB.GetDomainBlock( + gtscontext.SetBarebones(ctx), + permDraft.Domain, + ) + case gtsmodel.DomainPermissionAllow: + existing, err = p.state.DB.GetDomainAllow( + gtscontext.SetBarebones(ctx), + permDraft.Domain, + ) + } + + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting domain permission %s: %w", id, err) + return nil, "", gtserror.NewErrorInternalError(err) + } + + // Function to clean up the accepted draft, only called if + // creating or updating permission from draft is successful. + deleteDraft := func() { + if err := p.state.DB.DeleteDomainPermissionDraft(ctx, permDraft.ID); err != nil { + log.Errorf(ctx, "db error deleting domain permission draft: %v", err) + } + } + + switch { + + // Easy case, we just need to create a new domain + // permission from the draft, and then delete it. + case existing == nil: + var ( + new *apimodel.DomainPermission + actionID string + errWithCode gtserror.WithCode + ) + + if permDraft.PermissionType == gtsmodel.DomainPermissionBlock { + new, actionID, errWithCode = p.createDomainBlock( + ctx, + acct, + permDraft.Domain, + *permDraft.Obfuscate, + permDraft.PublicComment, + permDraft.PrivateComment, + permDraft.SubscriptionID, + ) + } + + if permDraft.PermissionType == gtsmodel.DomainPermissionAllow { + new, actionID, errWithCode = p.createDomainAllow( + ctx, + acct, + permDraft.Domain, + *permDraft.Obfuscate, + permDraft.PublicComment, + permDraft.PrivateComment, + permDraft.SubscriptionID, + ) + } + + // Clean up the draft + // before returning. + deleteDraft() + + return new, actionID, errWithCode + + // Domain permission exists but we should overwrite + // it by just updating the existing domain permission. + // Domain can't change, so no need to re-run side effects. + case overwrite: + existing.SetCreatedByAccountID(permDraft.CreatedByAccountID) + existing.SetCreatedByAccount(permDraft.CreatedByAccount) + existing.SetPrivateComment(permDraft.PrivateComment) + existing.SetPublicComment(permDraft.PublicComment) + existing.SetObfuscate(permDraft.Obfuscate) + existing.SetSubscriptionID(permDraft.SubscriptionID) + + var err error + switch dp := existing.(type) { + case *gtsmodel.DomainBlock: + err = p.state.DB.UpdateDomainBlock(ctx, dp) + + case *gtsmodel.DomainAllow: + err = p.state.DB.UpdateDomainAllow(ctx, dp) + } + + if err != nil { + err := gtserror.Newf("db error updating existing domain permission: %w", err) + return nil, "", gtserror.NewErrorInternalError(err) + } + + // Clean up the draft + // before returning. + deleteDraft() + + apiPerm, errWithCode := p.apiDomainPerm(ctx, existing, false) + return apiPerm, "", errWithCode + + // Domain permission exists and we shouldn't + // overwrite it, leave everything alone. + default: + const text = "a domain permission already exists with this permission type and domain" + err := fmt.Errorf("%w: %s", err, text) + return nil, "", gtserror.NewErrorConflict(err, text) + } +} + +func (p *Processor) DomainPermissionDraftRemove( + ctx context.Context, + acct *gtsmodel.Account, + id string, + ignoreTarget bool, +) (*apimodel.DomainPermission, gtserror.WithCode) { + permDraft, err := p.state.DB.GetDomainPermissionDraftByID(ctx, id) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting domain permission draft %s: %w", id, err) + return nil, gtserror.NewErrorInternalError(err) + } + + if permDraft == nil { + err := fmt.Errorf("domain permission draft %s not found", id) + return nil, gtserror.NewErrorNotFound(err, err.Error()) + } + + // Delete the permission draft. + if err := p.state.DB.DeleteDomainPermissionDraft(ctx, permDraft.ID); err != nil { + err := gtserror.Newf("db error deleting domain permission draft: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + if ignoreTarget { + // Add a domain permission ignore + // targeting the permDraft's domain. + _, err = p.DomainPermissionIgnoreCreate( + ctx, + acct, + permDraft.Domain, + permDraft.PrivateComment, + ) + if err != nil && !errors.Is(err, db.ErrAlreadyExists) { + err := gtserror.Newf("db error creating domain permission ignore: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + } + + return p.apiDomainPerm(ctx, permDraft, false) +} diff --git a/internal/processing/admin/domainpermissionignore.go b/internal/processing/admin/domainpermissionignore.go new file mode 100644 index 000000000..7229e32ad --- /dev/null +++ b/internal/processing/admin/domainpermissionignore.go @@ -0,0 +1,134 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package admin + +import ( + "context" + "errors" + "fmt" + "net/url" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/paging" +) + +func (p *Processor) DomainPermissionIgnoreCreate( + ctx context.Context, + acct *gtsmodel.Account, + domain string, + privateComment string, +) (*apimodel.DomainPermission, gtserror.WithCode) { + permIgnore := >smodel.DomainPermissionIgnore{ + ID: id.NewULID(), + Domain: domain, + CreatedByAccountID: acct.ID, + CreatedByAccount: acct, + PrivateComment: privateComment, + } + + if err := p.state.DB.PutDomainPermissionIgnore(ctx, permIgnore); err != nil { + if errors.Is(err, db.ErrAlreadyExists) { + const text = "a domain permission ignore already exists with this permission type and domain" + err := fmt.Errorf("%w: %s", err, text) + return nil, gtserror.NewErrorConflict(err, text) + } + + // Real error. + err := gtserror.Newf("db error putting domain permission ignore: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return p.apiDomainPerm(ctx, permIgnore, false) +} + +// DomainPermissionIgnoreGet returns one +// domain permission ignore with the given id. +func (p *Processor) DomainPermissionIgnoreGet( + ctx context.Context, + id string, +) (*apimodel.DomainPermission, gtserror.WithCode) { + permIgnore, err := p.state.DB.GetDomainPermissionIgnoreByID(ctx, id) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting domain permission ignore %s: %w", id, err) + return nil, gtserror.NewErrorInternalError(err) + } + + if permIgnore == nil { + err := fmt.Errorf("domain permission ignore %s not found", id) + return nil, gtserror.NewErrorNotFound(err, err.Error()) + } + + return p.apiDomainPerm(ctx, permIgnore, false) +} + +// DomainPermissionIgnoresGet returns a page of +// DomainPermissionIgnores with the given parameters. +func (p *Processor) DomainPermissionIgnoresGet( + ctx context.Context, + domain string, + page *paging.Page, +) (*apimodel.PageableResponse, gtserror.WithCode) { + permIgnores, err := p.state.DB.GetDomainPermissionIgnores( + ctx, + domain, + page, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + count := len(permIgnores) + if count == 0 { + return paging.EmptyResponse(), nil + } + + // Get the lowest and highest + // ID values, used for paging. + lo := permIgnores[count-1].ID + hi := permIgnores[0].ID + + // Convert each perm ignore to API model. + items := make([]any, len(permIgnores)) + for i, permIgnore := range permIgnores { + apiPermIgnore, err := p.apiDomainPerm(ctx, permIgnore, false) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + items[i] = apiPermIgnore + } + + // Assemble next/prev page queries. + query := make(url.Values, 1) + if domain != "" { + query.Set(apiutil.DomainPermissionDomainKey, domain) + } + + return paging.PackageResponse(paging.ResponseParams{ + Items: items, + Path: "/api/v1/admin/domain_permission_ignores", + Next: page.Next(lo, hi), + Prev: page.Prev(lo, hi), + Query: query, + }), nil +} diff --git a/internal/processing/admin/util.go b/internal/processing/admin/util.go index c82ff2dc1..bc59a2b3b 100644 --- a/internal/processing/admin/util.go +++ b/internal/processing/admin/util.go @@ -22,6 +22,7 @@ "errors" "time" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" @@ -97,3 +98,20 @@ func (p *Processor) rangeDomainAccounts( } } } + +// apiDomainPerm is a cheeky shortcut for returning +// the API version of the given domain permission, +// or an appropriate error if something goes wrong. +func (p *Processor) apiDomainPerm( + ctx context.Context, + domainPermission gtsmodel.DomainPermission, + export bool, +) (*apimodel.DomainPermission, gtserror.WithCode) { + apiDomainPerm, err := p.converter.DomainPermToAPIDomainPerm(ctx, domainPermission, export) + if err != nil { + err := gtserror.NewfAt(3, "error converting domain permission to api model: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return apiDomainPerm, nil +} diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index 03b24fc9c..54485aa12 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -1937,7 +1937,8 @@ func (c *Converter) ConversationToAPIConversation( return apiConversation, nil } -// DomainPermToAPIDomainPerm converts a gts model domin block or allow into an api domain permission. +// DomainPermToAPIDomainPerm converts a gtsmodel domain block, +// allow, draft, or ignore into an api domain permission. func (c *Converter) DomainPermToAPIDomainPerm( ctx context.Context, d gtsmodel.DomainPermission, @@ -1970,6 +1971,11 @@ func (c *Converter) DomainPermToAPIDomainPerm( domainPerm.CreatedBy = d.GetCreatedByAccountID() domainPerm.CreatedAt = util.FormatISO8601(d.GetCreatedAt()) + // If this is a draft, also add the permission type. + if _, ok := d.(*gtsmodel.DomainPermissionDraft); ok { + domainPerm.PermissionType = d.GetType().String() + } + return domainPerm, nil } diff --git a/web/source/settings/lib/navigation/menu.tsx b/web/source/settings/lib/navigation/menu.tsx index 514e3ea2f..2bd07a055 100644 --- a/web/source/settings/lib/navigation/menu.tsx +++ b/web/source/settings/lib/navigation/menu.tsx @@ -110,12 +110,19 @@ export function MenuItem(props: PropsWithChildren) { if (topLevel) { classNames.push("category", "top-level"); } else { - if (thisLevel === 1 && hasChildren) { - classNames.push("category", "expanding"); - } else if (thisLevel === 1 && !hasChildren) { - classNames.push("view", "expanding"); - } else if (thisLevel === 2) { - classNames.push("view", "nested"); + switch (true) { + case thisLevel === 1 && hasChildren: + classNames.push("category", "expanding"); + break; + case thisLevel === 1 && !hasChildren: + classNames.push("view", "expanding"); + break; + case thisLevel >= 2 && hasChildren: + classNames.push("nested", "category"); + break; + case thisLevel >= 2 && !hasChildren: + classNames.push("nested", "view"); + break; } } diff --git a/web/source/settings/lib/query/admin/domain-permissions/drafts.ts b/web/source/settings/lib/query/admin/domain-permissions/drafts.ts new file mode 100644 index 000000000..df5d0237f --- /dev/null +++ b/web/source/settings/lib/query/admin/domain-permissions/drafts.ts @@ -0,0 +1,85 @@ +/* + 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 . +*/ + +import { gtsApi } from "../../gts-api"; + +import type { + DomainPerm, + ModeratorDomainPermissionDraftSearchParams, + ModeratorDomainPermissionDraftSearchResp, +} from "../../../types/domain-permission"; +import parse from "parse-link-header"; + +const extended = gtsApi.injectEndpoints({ + endpoints: (build) => ({ + searchDomainPermissionDrafts: build.query({ + query: (form) => { + const params = new(URLSearchParams); + Object.entries(form).forEach(([k, v]) => { + if (v !== undefined) { + params.append(k, v); + } + }); + + let query = ""; + if (params.size !== 0) { + query = `?${params.toString()}`; + } + + return { + url: `/api/v1/admin/domain_permission_drafts${query}` + }; + }, + // Headers required for paging. + transformResponse: (apiResp: DomainPerm[], meta) => { + const drafts = apiResp; + const linksStr = meta?.response?.headers.get("Link"); + const links = parse(linksStr); + return { drafts, links }; + }, + // Only provide TRANSFORMED tag id since this model is not the same + // as getDomainPermissionDraft model (due to transformResponse). + providesTags: [{ type: "DomainPermissionDraft", id: "TRANSFORMED" }] + }), + + getDomainPermissionDraft: build.query({ + query: (id) => ({ + url: `/api/v1/admin/domain_permission_drafts/${id}` + }), + providesTags: (_result, _error, id) => [ + { type: 'DomainPermissionDraft', id } + ], + }), + }), +}); + +/** + * View domain permission drafts. + */ +const useLazySearchDomainPermissionDraftsQuery = extended.useLazySearchDomainPermissionDraftsQuery; + +/** + * Get domain permission draft with the given ID. + */ +const useGetDomainPermissionDraftQuery = extended.useGetDomainPermissionDraftQuery; + +export { + useLazySearchDomainPermissionDraftsQuery, + useGetDomainPermissionDraftQuery, +}; diff --git a/web/source/settings/lib/query/admin/domain-permissions/get.ts b/web/source/settings/lib/query/admin/domain-permissions/get.ts index 3e27742d4..ae7ac7960 100644 --- a/web/source/settings/lib/query/admin/domain-permissions/get.ts +++ b/web/source/settings/lib/query/admin/domain-permissions/get.ts @@ -37,6 +37,12 @@ const extended = gtsApi.injectEndpoints({ }), transformResponse: listToKeyedObject("domain"), }), + + domainPermissionDrafts: build.query({ + query: () => ({ + url: `/api/v1/admin/domain_permission_drafts` + }), + }), }), }); diff --git a/web/source/settings/lib/query/admin/domain-permissions/import.ts b/web/source/settings/lib/query/admin/domain-permissions/import.ts index dde488625..cbcf44964 100644 --- a/web/source/settings/lib/query/admin/domain-permissions/import.ts +++ b/web/source/settings/lib/query/admin/domain-permissions/import.ts @@ -24,7 +24,7 @@ import { type DomainPerm, type ImportDomainPermsParams, type MappedDomainPerms, - isDomainPermInternalKey, + stripOnImport, } from "../../../types/domain-permission"; import { listToKeyedObject } from "../../transforms"; @@ -83,7 +83,7 @@ function importEntriesProcessor(formData: ImportDomainPermsParams): (_entry: Dom // Unset all internal processing keys // and any undefined keys on this entry. Object.entries(entry).forEach(([key, val]: [keyof DomainPerm, any]) => { - if (val == undefined || isDomainPermInternalKey(key)) { + if (val == undefined || stripOnImport(key)) { delete entry[key]; } }); diff --git a/web/source/settings/lib/query/gts-api.ts b/web/source/settings/lib/query/gts-api.ts index 911ea58c7..6a420088e 100644 --- a/web/source/settings/lib/query/gts-api.ts +++ b/web/source/settings/lib/query/gts-api.ts @@ -169,6 +169,7 @@ export const gtsApi = createApi({ "HTTPHeaderBlocks", "DefaultInteractionPolicies", "InteractionRequest", + "DomainPermissionDraft", ], endpoints: (build) => ({ instanceV1: build.query({ diff --git a/web/source/settings/lib/types/domain-permission.ts b/web/source/settings/lib/types/domain-permission.ts index ccf7c9c57..51a868f1e 100644 --- a/web/source/settings/lib/types/domain-permission.ts +++ b/web/source/settings/lib/types/domain-permission.ts @@ -19,11 +19,12 @@ import typia from "typia"; import { PermType } from "./perm"; +import { Links } from "parse-link-header"; export const validateDomainPerms = typia.createValidate(); /** - * A single domain permission entry (block or allow). + * A single domain permission entry (block, allow, draft, ignore). */ export interface DomainPerm { id?: string; @@ -33,10 +34,11 @@ export interface DomainPerm { public_comment?: string; created_at?: string; - // Internal processing keys; remove - // before serdes of domain perm. + // Keys that should be stripped before + // sending the domain permission (if imported). + + permission_type?: PermType; key?: string; - permType?: PermType; suggest?: string; valid?: boolean; checked?: boolean; @@ -53,9 +55,9 @@ export interface MappedDomainPerms { [key: string]: DomainPerm; } -const domainPermInternalKeys: Set = new Set([ +const domainPermStripOnImport: Set = new Set([ "key", - "permType", + "permission_type", "suggest", "valid", "checked", @@ -65,15 +67,14 @@ const domainPermInternalKeys: Set = new Set([ ]); /** - * Returns true if provided DomainPerm Object key is - * "internal"; ie., it's just for our use, and it shouldn't - * be serialized to or deserialized from the GtS API. + * Returns true if provided DomainPerm Object key is one + * that should be stripped when importing a domain permission. * * @param key * @returns */ -export function isDomainPermInternalKey(key: keyof DomainPerm) { - return domainPermInternalKeys.has(key); +export function stripOnImport(key: keyof DomainPerm) { + return domainPermStripOnImport.has(key); } export interface ImportDomainPermsParams { @@ -94,3 +95,75 @@ export interface ExportDomainPermsParams { action: "export" | "export-file"; exportType: "json" | "csv" | "plain"; } + +/** + * Parameters for GET to /api/v1/admin/domain_permission_drafts. + */ +export interface ModeratorDomainPermissionDraftSearchParams { + /** + * Show only drafts created by the given subscription ID. + */ + subscription_id?: string; + /** + * Return only drafts that target the given domain. + */ + domain?: string; + /** + * Filter on "block" or "allow" type drafts. + */ + permission_type?: PermType; + /** + * 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. + */ + max_id?: string; + /** + * Return only items *NEWER* than the given since ID. + * The item with the specified ID will not be included in the response. + */ + since_id?: string; + /** + * 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. + */ + min_id?: string; + /** + * Number of items to return. + */ + limit?: number; +} + +/** + * Parameters for POST to /api/v1/admin/domain_permission_drafts/{id}/accept. + */ +export interface ModeratorDomainPermissionDraftAcceptParams { + /** + * ID of the domain permission draft. + */ + id: string; + /** + * If a domain permission already exists with the same domain and permission + * type as the draft, overwrite the existing permission with fields from the draft. + */ + overwrite?: boolean; +} + +/** + * Parameters for POST to /api/v1/admin/domain_permission_drafts/{id}/accept. + */ +export interface ModeratorDomainPermissionDraftRejectParams { + /** + * ID of the domain permission draft. + */ + id: string; + /** + * When removing the domain permission draft, also create a domain ignore entry for + * the target domain, so that drafts will not be created for this domain in the future. + */ + ignore_target?: boolean; +} + +export interface ModeratorDomainPermissionDraftSearchResp { + drafts: DomainPerm[]; + links: Links | null; +} diff --git a/web/source/settings/style.css b/web/source/settings/style.css index ecfe5910a..7a92ea094 100644 --- a/web/source/settings/style.css +++ b/web/source/settings/style.css @@ -194,7 +194,8 @@ nav.menu-tree { } } - li.nested { /* any deeper nesting, just has indent */ + /* Deeper nesting. */ + li.nested { a.title { padding-left: 1rem; font-weight: normal; @@ -210,11 +211,35 @@ nav.menu-tree { background: $settings-nav-bg-hover; } } + + &.active > a.title { + color: $fg-accent; + font-weight: bold; + } - &.active { - a.title { - color: $fg-accent; - font-weight: bold; + &.category { + & > a.title { + &::after { + content: "▶"; + left: 0.8rem; + bottom: 0.1rem; + position: relative; + } + } + + &.active { + & > a.title { + &::after { + content: "▼"; + bottom: 0; + } + + border-bottom: 0.1rem dotted $gray1; + } + } + + li.nested > a.title { + padding-left: 2rem; } } } diff --git a/web/source/settings/views/moderation/domain-permissions/drafts/index.tsx b/web/source/settings/views/moderation/domain-permissions/drafts/index.tsx new file mode 100644 index 000000000..1bba8fffc --- /dev/null +++ b/web/source/settings/views/moderation/domain-permissions/drafts/index.tsx @@ -0,0 +1,193 @@ +/* + 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 . +*/ + +import React, { ReactNode, useEffect, useMemo } from "react"; + +import { useTextInput } from "../../../../lib/form"; +import { PageableList } from "../../../../components/pageable-list"; +import MutationButton from "../../../../components/form/mutation-button"; +import { useLocation, useSearch } from "wouter"; +import { useLazySearchDomainPermissionDraftsQuery } from "../../../../lib/query/admin/domain-permissions/drafts"; +import { DomainPerm } from "../../../../lib/types/domain-permission"; + +export default function DomainPermissionDraftsSearch() { + return ( +
+
+

Domain Permission Drafts

+

+ You can use the form below to search through domain permission drafts. +
+ Domain permission drafts are domain block or domain allow entries that are not yet in force. +
+ You can choose to accept or remove a draft. +

+
+ +
+ ); +} + +function DomainPermissionDraftsSearchForm() { + const [ location, setLocation ] = useLocation(); + const search = useSearch(); + const urlQueryParams = useMemo(() => new URLSearchParams(search), [search]); + const hasParams = urlQueryParams.size != 0; + const [ searchDrafts, searchRes ] = useLazySearchDomainPermissionDraftsQuery(); + + const form = { + subscription_id: useTextInput("subscription_id", { defaultValue: urlQueryParams.get("subscription_id") ?? "" }), + domain: useTextInput("domain", { defaultValue: urlQueryParams.get("domain") ?? "" }), + permission_type: useTextInput("permission_type", { defaultValue: urlQueryParams.get("permission_type") ?? "" }), + limit: useTextInput("limit", { defaultValue: urlQueryParams.get("limit") ?? "20" }) + }; + + // On mount, if urlQueryParams were provided, + // trigger the search. For example, if page + // was accessed at /search?origin=local&limit=20, + // then run a search with origin=local and + // limit=20 and immediately render the results. + // + // If no urlQueryParams set, trigger default + // search (first page, no filtering). + useEffect(() => { + if (hasParams) { + searchDrafts(Object.fromEntries(urlQueryParams)); + } else { + setLocation(location + "?limit=20"); + } + }, [ + urlQueryParams, + hasParams, + searchDrafts, + location, + setLocation, + ]); + + // Rather than triggering the search directly, + // the "submit" button changes the location + // based on form field params, and lets the + // useEffect hook above actually do the search. + function submitQuery(e) { + e.preventDefault(); + + // Parse query parameters. + const entries = Object.entries(form).map(([k, v]) => { + // Take only defined form fields. + if (v.value === undefined || v.value.length === 0 || v.value === "any") { + return null; + } + return [[k, v.value]]; + }).flatMap(kv => { + // Remove any nulls. + return kv || []; + }); + + const searchParams = new URLSearchParams(entries); + setLocation(location + "?" + searchParams.toString()); + } + + // Location to return to when user clicks "back" on the detail view. + const backLocation = location + (hasParams ? `?${urlQueryParams}` : ""); + + // Function to map an item to a list entry. + function itemToEntry(draft: DomainPerm): ReactNode { + return ( + + ); + } + + return ( + <> +
+ {/* */} + + + No drafts found that match your query.} + prevNextLinks={searchRes.data?.links} + /> + + ); +} + +interface DraftEntryProps { + draft: DomainPerm; + linkTo: string; + backLocation: string; +} + +function DraftListEntry({ draft, linkTo, backLocation }: DraftEntryProps) { + const [ _location, setLocation ] = useLocation(); + + return ( + { + // When clicking on a draft, direct + // to the detail view for that draft. + setLocation(linkTo, { + // Store the back location in history so + // the detail view can use it to return to + // this page (including query parameters). + state: { backLocation: backLocation } + }); + }} + role="link" + tabIndex={0} + > +
+ +
+
+ ); +} diff --git a/web/source/settings/views/moderation/domain-permissions/drafts/new.tsx b/web/source/settings/views/moderation/domain-permissions/drafts/new.tsx new file mode 100644 index 000000000..badde6a3f --- /dev/null +++ b/web/source/settings/views/moderation/domain-permissions/drafts/new.tsx @@ -0,0 +1,24 @@ +/* + 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 . +*/ + +import React from "react"; + +export default function DomainPermissionDraftNew() { + return <>; +} diff --git a/web/source/settings/views/moderation/menu.tsx b/web/source/settings/views/moderation/menu.tsx index 9488b8c30..ab4e26be9 100644 --- a/web/source/settings/views/moderation/menu.tsx +++ b/web/source/settings/views/moderation/menu.tsx @@ -116,6 +116,23 @@ function ModerationDomainPermsMenu() { itemUrl="import-export" icon="fa-floppy-o" /> + + + + ); } diff --git a/web/source/settings/views/moderation/router.tsx b/web/source/settings/views/moderation/router.tsx index 93f7e481a..1cc615a00 100644 --- a/web/source/settings/views/moderation/router.tsx +++ b/web/source/settings/views/moderation/router.tsx @@ -29,6 +29,8 @@ import DomainPermDetail from "./domain-permissions/detail"; import AccountsSearch from "./accounts"; import AccountsPending from "./accounts/pending"; import AccountDetail from "./accounts/detail"; +import DomainPermissionDraftsSearch from "./domain-permissions/drafts"; +import DomainPermissionDraftNew from "./domain-permissions/drafts/new"; /* EXPORTED COMPONENTS @@ -139,6 +141,8 @@ function ModerationDomainPermsRouter() { + +