diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index d10aa5daa..e76e4e6cf 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -1130,6 +1130,100 @@ definitions: type: object x-go-name: DomainPermission x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model + domainPermissionSubscription: + properties: + adopt_orphans: + description: 'If true, this domain permission subscription will "adopt" domain permissions which already exist on the instance, and which meet the following conditions: 1) they have no subscription ID (ie., they''re "orphaned") and 2) they are present in the subscribed list. Such orphaned domain permissions will be given this subscription''s subscription ID value.' + example: false + type: boolean + x-go-name: AdoptOrphans + 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 use when parsing the permissions list. + example: text/csv + type: string + x-go-name: ContentType + count: + description: Count of domain permission entries discovered at URI on last (successful) fetch. + example: 53 + format: uint64 + readOnly: true + type: integer + x-go-name: Count + created_at: + description: Time at which the subscription was created (ISO 8601 Datetime). + example: "2021-07-30T09:20:25+00:00" + type: string + x-go-name: CreatedAt + created_by: + description: ID of the account that created this subscription. + example: 01FBW21XJA09XYX51KV5JVBW0F + readOnly: true + type: string + x-go-name: CreatedBy + 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 of the most recent fetch attempt (successful or otherwise) (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 + priority: + description: Priority of this subscription compared to others of the same permission type. 0-255 (higher = higher priority). + example: 100 + format: uint8 + type: integer + x-go-name: Priority + successfully_fetched_at: + description: Time of the most recent successful fetch (ISO 8601 Datetime). + example: "2021-07-30T09:20:25+00:00" + readOnly: true + type: string + x-go-name: SuccessfullyFetchedAt + title: + description: Title of this subscription, as set by admin who created or updated it. + example: really cool list of neato pals + type: string + x-go-name: Title + 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: @@ -6050,6 +6144,335 @@ paths: summary: Get domain permission exclude with the given ID. tags: - admin + /api/v1/admin/domain_permission_subscriptions: + get: + description: |- + The subscriptions will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer). + + The next and previous queries can be parsed from the returned Link header. + + Example: + + ``` + ; rel="next", ; rel="prev" + ```` + operationId: domainPermissionSubscriptionsGet + parameters: + - description: Filter on "block" or "allow" type subscriptions. + 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 subscriptions. + headers: + Link: + description: Links to the next and previous queries. + type: string + schema: + items: + $ref: '#/definitions/domainPermissionSubscription' + 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 subscriptions. + tags: + - admin + post: + consumes: + - multipart/form-data + - application/json + operationId: domainPermissionSubscriptionCreate + parameters: + - default: 0 + description: Priority of this subscription compared to others of the same permission type. 0-255 (higher = higher priority). Higher priority subscriptions will overwrite permissions generated by lower priority subscriptions. When two subscriptions have the same `priority` value, priority is indeterminate, so it's recommended to always set this value manually. + in: formData + maximum: 255 + minimum: 0 + name: priority + type: number + - description: Optional title for this subscription. + in: formData + name: title + type: string + - description: Type of permissions to create by parsing the targeted file/list. One of "allow" or "block". + in: formData + name: permission_type + required: true + type: string + - default: true + description: If true, domain permissions arising from this subscription will be created as drafts that must be approved by a moderator to take effect. If false, domain permissions from this subscription will come into force immediately. Defaults to "true". + in: formData + name: as_draft + type: boolean + - default: false + description: 'If true, this domain permission subscription will "adopt" domain permissions which already exist on the instance, and which meet the following conditions: 1) they have no subscription ID (ie., they''re "orphaned") and 2) they are present in the subscribed list. Such orphaned domain permissions will be given this subscription''s subscription ID value and be managed by this subscription.' + in: formData + name: adopt_orphans + type: boolean + - description: URI to call in order to fetch the permissions list. + in: formData + name: uri + required: true + type: string + - description: MIME content type to use when parsing the permissions list. One of "text/plain", "text/csv", and "application/json". + in: formData + name: content_type + required: true + type: string + - description: Optional basic auth username to provide when fetching given uri. If set, will be transmitted along with `fetch_password` when doing the fetch. + in: formData + name: fetch_username + type: string + - description: Optional basic auth password to provide when fetching given uri. If set, will be transmitted along with `fetch_username` when doing the fetch. + in: formData + name: fetch_password + type: string + produces: + - application/json + responses: + "200": + description: The newly created domain permission subscription. + schema: + $ref: '#/definitions/domainPermissionSubscription' + "400": + description: bad request + "401": + description: unauthorized + "403": + description: forbidden + "406": + description: not acceptable + "409": + description: conflict + "500": + description: internal server error + security: + - OAuth2 Bearer: + - admin + summary: Create a domain permission subscription with the given parameters. + tags: + - admin + /api/v1/admin/domain_permission_subscriptions/${id}: + patch: + consumes: + - multipart/form-data + - application/json + operationId: domainPermissionSubscriptionUpdate + parameters: + - description: ID of the domain permission subscription. + in: path + name: id + required: true + type: string + - description: Priority of this subscription compared to others of the same permission type. 0-255 (higher = higher priority). Higher priority subscriptions will overwrite permissions generated by lower priority subscriptions. When two subscriptions have the same `priority` value, priority is indeterminate, so it's recommended to always set this value manually. + in: formData + maximum: 255 + minimum: 0 + name: priority + type: number + - description: Optional title for this subscription. + in: formData + name: title + type: string + - description: URI to call in order to fetch the permissions list. + in: formData + name: uri + type: string + - default: true + description: If true, domain permissions arising from this subscription will be created as drafts that must be approved by a moderator to take effect. If false, domain permissions from this subscription will come into force immediately. Defaults to "true". + in: formData + name: as_draft + type: boolean + - default: false + description: 'If true, this domain permission subscription will "adopt" domain permissions which already exist on the instance, and which meet the following conditions: 1) they have no subscription ID (ie., they''re "orphaned") and 2) they are present in the subscribed list. Such orphaned domain permissions will be given this subscription''s subscription ID value and be managed by this subscription.' + in: formData + name: adopt_orphans + type: boolean + - description: MIME content type to use when parsing the permissions list. One of "text/plain", "text/csv", and "application/json". + in: formData + name: content_type + type: string + - description: Optional basic auth username to provide when fetching given uri. If set, will be transmitted along with `fetch_password` when doing the fetch. + in: formData + name: fetch_username + type: string + - description: Optional basic auth password to provide when fetching given uri. If set, will be transmitted along with `fetch_username` when doing the fetch. + in: formData + name: fetch_password + type: string + produces: + - application/json + responses: + "200": + description: The updated domain permission subscription. + schema: + $ref: '#/definitions/domainPermissionSubscription' + "400": + description: bad request + "401": + description: unauthorized + "403": + description: forbidden + "406": + description: not acceptable + "409": + description: conflict + "500": + description: internal server error + security: + - OAuth2 Bearer: + - admin + summary: Update a domain permission subscription with the given parameters. + tags: + - admin + /api/v1/admin/domain_permission_subscriptions/{id}: + get: + operationId: domainPermissionSubscriptionGet + parameters: + - description: ID of the domain permission subscription. + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: Domain permission subscription. + schema: + $ref: '#/definitions/domainPermissionSubscription' + "401": + description: unauthorized + "403": + description: forbidden + "404": + description: not found + "406": + description: not acceptable + "500": + description: internal server error + security: + - OAuth2 Bearer: + - admin + summary: Get domain permission subscription with the given ID. + tags: + - admin + /api/v1/admin/domain_permission_subscriptions/{id}/remove: + post: + consumes: + - multipart/form-data + - application/json + operationId: domainPermissionSubscriptionRemove + parameters: + - description: ID of the domain permission subscription. + in: path + name: id + required: true + type: string + - default: true + description: |- + When removing the domain permission subscription, also remove children of this subscription, ie., domain permissions that are managed by this subscription. If false, then children will instead be orphaned but not removed. + Note that removed permissions may end up being created again later by another domain permission subscription of lower priority than the removed subscription. Likewise, orphaned children may be later adopted by another subscription. + in: formData + name: remove_children + type: boolean + produces: + - application/json + responses: + "200": + description: The removed domain permission subscription. + schema: + $ref: '#/definitions/domainPermissionSubscription' + "400": + description: bad request + "401": + description: unauthorized + "403": + description: forbidden + "406": + description: not acceptable + "409": + description: conflict + "500": + description: internal server error + security: + - OAuth2 Bearer: + - admin + summary: Remove a domain permission subscription. + tags: + - admin + /api/v1/admin/domain_permission_subscriptions/preview: + get: + description: This view allows you to see the order in which domain permissions will actually be fetched and created. + operationId: domainPermissionSubscriptionsPreviewGet + parameters: + - description: Filter on "block" or "allow" type subscriptions. + in: query + name: permission_type + required: true + type: string + produces: + - application/json + responses: + "200": + description: Domain permission subscriptions. + schema: + items: + $ref: '#/definitions/domainPermissionSubscription' + 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 all domain permission subscriptions of the given permission type, in priority order (highest to lowest). + 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 a33a6448a..68a088b4d 100644 --- a/internal/api/client/admin/admin.go +++ b/internal/api/client/admin/admin.go @@ -28,43 +28,47 @@ ) const ( - BasePath = "/v1/admin" - EmojiPath = BasePath + "/custom_emojis" - EmojiPathWithID = EmojiPath + "/:" + apiutil.IDKey - EmojiCategoriesPath = EmojiPath + "/categories" - DomainBlocksPath = BasePath + "/domain_blocks" - DomainBlocksPathWithID = DomainBlocksPath + "/:" + apiutil.IDKey - DomainAllowsPath = BasePath + "/domain_allows" - DomainAllowsPathWithID = DomainAllowsPath + "/:" + apiutil.IDKey - DomainPermissionDraftsPath = BasePath + "/domain_permission_drafts" - DomainPermissionDraftsPathWithID = DomainPermissionDraftsPath + "/:" + apiutil.IDKey - DomainPermissionDraftAcceptPath = DomainPermissionDraftsPathWithID + "/accept" - DomainPermissionDraftRemovePath = DomainPermissionDraftsPathWithID + "/remove" - DomainPermissionExcludesPath = BasePath + "/domain_permission_excludes" - DomainPermissionExcludesPathWithID = DomainPermissionExcludesPath + "/:" + apiutil.IDKey - DomainKeysExpirePath = BasePath + "/domain_keys_expire" - HeaderAllowsPath = BasePath + "/header_allows" - HeaderAllowsPathWithID = HeaderAllowsPath + "/:" + apiutil.IDKey - HeaderBlocksPath = BasePath + "/header_blocks" - HeaderBlocksPathWithID = HeaderBlocksPath + "/:" + apiutil.IDKey - AccountsV1Path = BasePath + "/accounts" - AccountsV2Path = "/v2/admin/accounts" - AccountsPathWithID = AccountsV1Path + "/:" + apiutil.IDKey - AccountsActionPath = AccountsPathWithID + "/action" - AccountsApprovePath = AccountsPathWithID + "/approve" - AccountsRejectPath = AccountsPathWithID + "/reject" - MediaCleanupPath = BasePath + "/media_cleanup" - MediaRefetchPath = BasePath + "/media_refetch" - ReportsPath = BasePath + "/reports" - ReportsPathWithID = ReportsPath + "/:" + apiutil.IDKey - ReportsResolvePath = ReportsPathWithID + "/resolve" - EmailPath = BasePath + "/email" - EmailTestPath = EmailPath + "/test" - InstanceRulesPath = BasePath + "/instance/rules" - InstanceRulesPathWithID = InstanceRulesPath + "/:" + apiutil.IDKey - DebugPath = BasePath + "/debug" - DebugAPUrlPath = DebugPath + "/apurl" - DebugClearCachesPath = DebugPath + "/caches/clear" + BasePath = "/v1/admin" + EmojiPath = BasePath + "/custom_emojis" + EmojiPathWithID = EmojiPath + "/:" + apiutil.IDKey + EmojiCategoriesPath = EmojiPath + "/categories" + DomainBlocksPath = BasePath + "/domain_blocks" + DomainBlocksPathWithID = DomainBlocksPath + "/:" + apiutil.IDKey + DomainAllowsPath = BasePath + "/domain_allows" + DomainAllowsPathWithID = DomainAllowsPath + "/:" + apiutil.IDKey + DomainPermissionDraftsPath = BasePath + "/domain_permission_drafts" + DomainPermissionDraftsPathWithID = DomainPermissionDraftsPath + "/:" + apiutil.IDKey + DomainPermissionDraftAcceptPath = DomainPermissionDraftsPathWithID + "/accept" + DomainPermissionDraftRemovePath = DomainPermissionDraftsPathWithID + "/remove" + DomainPermissionExcludesPath = BasePath + "/domain_permission_excludes" + DomainPermissionExcludesPathWithID = DomainPermissionExcludesPath + "/:" + apiutil.IDKey + DomainPermissionSubscriptionsPath = BasePath + "/domain_permission_subscriptions" + DomainPermissionSubscriptionsPathWithID = DomainPermissionSubscriptionsPath + "/:" + apiutil.IDKey + DomainPermissionSubscriptionsPreviewPath = DomainPermissionSubscriptionsPath + "/preview" + DomainPermissionSubscriptionRemovePath = DomainPermissionSubscriptionsPathWithID + "/remove" + DomainKeysExpirePath = BasePath + "/domain_keys_expire" + HeaderAllowsPath = BasePath + "/header_allows" + HeaderAllowsPathWithID = HeaderAllowsPath + "/:" + apiutil.IDKey + HeaderBlocksPath = BasePath + "/header_blocks" + HeaderBlocksPathWithID = HeaderBlocksPath + "/:" + apiutil.IDKey + AccountsV1Path = BasePath + "/accounts" + AccountsV2Path = "/v2/admin/accounts" + AccountsPathWithID = AccountsV1Path + "/:" + apiutil.IDKey + AccountsActionPath = AccountsPathWithID + "/action" + AccountsApprovePath = AccountsPathWithID + "/approve" + AccountsRejectPath = AccountsPathWithID + "/reject" + MediaCleanupPath = BasePath + "/media_cleanup" + MediaRefetchPath = BasePath + "/media_refetch" + ReportsPath = BasePath + "/reports" + ReportsPathWithID = ReportsPath + "/:" + apiutil.IDKey + ReportsResolvePath = ReportsPathWithID + "/resolve" + EmailPath = BasePath + "/email" + EmailTestPath = EmailPath + "/test" + InstanceRulesPath = BasePath + "/instance/rules" + InstanceRulesPathWithID = InstanceRulesPath + "/:" + apiutil.IDKey + DebugPath = BasePath + "/debug" + DebugAPUrlPath = DebugPath + "/apurl" + DebugClearCachesPath = DebugPath + "/caches/clear" FilterQueryKey = "filter" MaxShortcodeDomainKey = "max_shortcode_domain" @@ -118,6 +122,14 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H attachHandler(http.MethodGet, DomainPermissionExcludesPathWithID, m.DomainPermissionExcludeGETHandler) attachHandler(http.MethodDelete, DomainPermissionExcludesPathWithID, m.DomainPermissionExcludeDELETEHandler) + // domain permission subscriptions stuff + attachHandler(http.MethodPost, DomainPermissionSubscriptionsPath, m.DomainPermissionSubscriptionPOSTHandler) + attachHandler(http.MethodGet, DomainPermissionSubscriptionsPath, m.DomainPermissionSubscriptionsGETHandler) + attachHandler(http.MethodGet, DomainPermissionSubscriptionsPreviewPath, m.DomainPermissionSubscriptionsPreviewGETHandler) + attachHandler(http.MethodGet, DomainPermissionSubscriptionsPathWithID, m.DomainPermissionSubscriptionGETHandler) + attachHandler(http.MethodPatch, DomainPermissionSubscriptionsPathWithID, m.DomainPermissionSubscriptionPATCHHandler) + attachHandler(http.MethodPost, DomainPermissionSubscriptionRemovePath, m.DomainPermissionSubscriptionRemovePOSTHandler) + // header filtering administration routes attachHandler(http.MethodGet, HeaderAllowsPathWithID, m.HeaderFilterAllowGET) attachHandler(http.MethodGet, HeaderBlocksPathWithID, m.HeaderFilterBlockGET) diff --git a/internal/api/client/admin/domainpermission.go b/internal/api/client/admin/domainpermission.go index 90c0eb4c0..5138be898 100644 --- a/internal/api/client/admin/domainpermission.go +++ b/internal/api/client/admin/domainpermission.go @@ -302,3 +302,45 @@ func (m *Module) getDomainPermissions( apiutil.JSON(c, http.StatusOK, domainPerm) } + +// parseDomainPermissionType is a util function to parse i +// to a DomainPermissionType, or return a suitable error. +func parseDomainPermissionType(i string) ( + permType gtsmodel.DomainPermissionType, + errWithCode gtserror.WithCode, +) { + if i == "" { + const errText = "permission_type not set, must be one of block or allow" + errWithCode = gtserror.NewErrorBadRequest(errors.New(errText), errText) + return + } + + permType = gtsmodel.ParseDomainPermissionType(i) + if permType == gtsmodel.DomainPermissionUnknown { + var errText = fmt.Sprintf("permission_type %s not recognized, must be one of block or allow", i) + errWithCode = gtserror.NewErrorBadRequest(errors.New(errText), errText) + } + + return +} + +// parseDomainPermSubContentType is a util function to parse i +// to a DomainPermSubContentType, or return a suitable error. +func parseDomainPermSubContentType(i string) ( + contentType gtsmodel.DomainPermSubContentType, + errWithCode gtserror.WithCode, +) { + if i == "" { + const errText = "content_type not set, must be one of text/csv, text/plain or application/json" + errWithCode = gtserror.NewErrorBadRequest(errors.New(errText), errText) + return + } + + contentType = gtsmodel.NewDomainPermSubContentType(i) + if contentType == gtsmodel.DomainPermSubContentTypeUnknown { + var errText = fmt.Sprintf("content_type %s not recognized, must be one of text/csv, text/plain or application/json", i) + errWithCode = gtserror.NewErrorBadRequest(errors.New(errText), errText) + } + + return +} diff --git a/internal/api/client/admin/domainpermissiondraftcreate.go b/internal/api/client/admin/domainpermissiondraftcreate.go index d20842ebf..ec94f947b 100644 --- a/internal/api/client/admin/domainpermissiondraftcreate.go +++ b/internal/api/client/admin/domainpermissiondraftcreate.go @@ -26,7 +26,6 @@ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -136,24 +135,8 @@ func (m *Module) DomainPermissionDraftsPOSTHandler(c *gin.Context) { return } - var ( - permType gtsmodel.DomainPermissionType - errText string - ) - - switch pt := form.PermissionType; pt { - case "block": - permType = gtsmodel.DomainPermissionBlock - case "allow": - permType = gtsmodel.DomainPermissionAllow - case "": - errText = "permission_type not set, must be one of block or allow" - default: - errText = fmt.Sprintf("permission_type %s not recognized, must be one of block or allow", pt) - } - - if errText != "" { - errWithCode := gtserror.NewErrorBadRequest(errors.New(errText), errText) + permType, errWithCode := parseDomainPermissionType(form.PermissionType) + if errWithCode != nil { apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return } diff --git a/internal/api/client/admin/domainpermissiondraftsget.go b/internal/api/client/admin/domainpermissiondraftsget.go index d63179afc..21ce5dc43 100644 --- a/internal/api/client/admin/domainpermissiondraftsget.go +++ b/internal/api/client/admin/domainpermissiondraftsget.go @@ -149,7 +149,7 @@ func (m *Module) DomainPermissionDraftsGETHandler(c *gin.Context) { permTypeStr := c.Query(apiutil.DomainPermissionPermTypeKey) permType := gtsmodel.ParseDomainPermissionType(permTypeStr) - if permType == gtsmodel.DomainPermissionUnknown { + if permTypeStr != "" && permType == gtsmodel.DomainPermissionUnknown { text := fmt.Sprintf( "permission_type %s not recognized, valid values are empty string, block, or allow", permTypeStr, diff --git a/internal/api/client/admin/domainpermissionsubscriptioncreate.go b/internal/api/client/admin/domainpermissionsubscriptioncreate.go new file mode 100644 index 000000000..dd0b43aca --- /dev/null +++ b/internal/api/client/admin/domainpermissionsubscriptioncreate.go @@ -0,0 +1,244 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package admin + +import ( + "errors" + "fmt" + "net/http" + "net/url" + + "github.com/gin-gonic/gin" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +// DomainPermissionSubscriptionPOSTHandler swagger:operation POST /api/v1/admin/domain_permission_subscriptions domainPermissionSubscriptionCreate +// +// Create a domain permission subscription with the given parameters. +// +// --- +// tags: +// - admin +// +// consumes: +// - multipart/form-data +// - application/json +// +// produces: +// - application/json +// +// parameters: +// - +// name: priority +// in: formData +// description: >- +// Priority of this subscription compared to others of the same permission type. +// 0-255 (higher = higher priority). Higher priority subscriptions will overwrite +// permissions generated by lower priority subscriptions. When two subscriptions +// have the same `priority` value, priority is indeterminate, so it's recommended +// to always set this value manually. +// type: number +// minimum: 0 +// maximum: 255 +// default: 0 +// - +// name: title +// in: formData +// description: Optional title for this subscription. +// type: string +// - +// name: permission_type +// required: true +// in: formData +// description: >- +// Type of permissions to create by parsing the targeted file/list. +// One of "allow" or "block". +// type: string +// - +// name: as_draft +// in: formData +// description: >- +// If true, domain permissions arising from this subscription will be +// created as drafts that must be approved by a moderator to take effect. +// If false, domain permissions from this subscription will come into force immediately. +// Defaults to "true". +// type: boolean +// default: true +// - +// name: adopt_orphans +// in: formData +// description: >- +// If true, this domain permission subscription will "adopt" domain permissions +// which already exist on the instance, and which meet the following conditions: +// 1) they have no subscription ID (ie., they're "orphaned") and 2) they are present +// in the subscribed list. Such orphaned domain permissions will be given this +// subscription's subscription ID value and be managed by this subscription. +// type: boolean +// default: false +// - +// name: uri +// required: true +// in: formData +// description: URI to call in order to fetch the permissions list. +// type: string +// - +// name: content_type +// required: true +// in: formData +// description: >- +// MIME content type to use when parsing the permissions list. +// One of "text/plain", "text/csv", and "application/json". +// type: string +// - +// name: fetch_username +// in: formData +// description: >- +// Optional basic auth username to provide when fetching given uri. +// If set, will be transmitted along with `fetch_password` when doing the fetch. +// type: string +// - +// name: fetch_password +// in: formData +// description: >- +// Optional basic auth password to provide when fetching given uri. +// If set, will be transmitted along with `fetch_username` when doing the fetch. +// type: string +// +// security: +// - OAuth2 Bearer: +// - admin +// +// responses: +// '200': +// description: The newly created domain permission subscription. +// schema: +// "$ref": "#/definitions/domainPermissionSubscription" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '406': +// description: not acceptable +// '409': +// description: conflict +// '500': +// description: internal server error +func (m *Module) DomainPermissionSubscriptionPOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if !*authed.User.Admin { + err := fmt.Errorf("user %s not an admin", authed.User.ID) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if authed.Account.IsMoving() { + apiutil.ForbiddenAfterMove(c) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + // Parse + validate form. + form := new(apimodel.DomainPermissionSubscriptionRequest) + if err := c.ShouldBind(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + return + } + + // Check priority. + // Default to 0. + priority := util.PtrOrZero(form.Priority) + if priority < 0 || priority > 255 { + const errText = "priority must be a number in the range 0 to 255" + errWithCode := gtserror.NewErrorBadRequest(errors.New(errText), errText) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + // Ensure URI is set. + if form.URI == nil { + const errText = "uri must be set" + errWithCode := gtserror.NewErrorBadRequest(errors.New(errText), errText) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + // Ensure URI is parseable. + uri, err := url.Parse(*form.URI) + if err != nil { + err := fmt.Errorf("invalid uri provided: %w", err) + errWithCode := gtserror.NewErrorBadRequest(err, err.Error()) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + // Normalize URI by converting back to string. + uriStr := uri.String() + + // Content type must be set. + contentTypeStr := util.PtrOrZero(form.ContentType) + contentType, errWithCode := parseDomainPermSubContentType(contentTypeStr) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + // Permission type must be set. + permTypeStr := util.PtrOrZero(form.PermissionType) + permType, errWithCode := parseDomainPermissionType(permTypeStr) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + // Default `as_draft` to true. + asDraft := util.PtrOrValue(form.AsDraft, true) + + permSub, errWithCode := m.processor.Admin().DomainPermissionSubscriptionCreate( + c.Request.Context(), + authed.Account, + uint8(priority), // #nosec G115 -- Validated above. + util.PtrOrZero(form.Title), // Optional. + uriStr, + contentType, + permType, + asDraft, + util.PtrOrZero(form.FetchUsername), // Optional. + util.PtrOrZero(form.FetchPassword), // Optional. + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, permSub) +} diff --git a/internal/api/client/admin/domainpermissionsubscriptionget.go b/internal/api/client/admin/domainpermissionsubscriptionget.go new file mode 100644 index 000000000..841e37f24 --- /dev/null +++ b/internal/api/client/admin/domainpermissionsubscriptionget.go @@ -0,0 +1,104 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package admin + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// DomainPermissionSubscriptionGETHandler swagger:operation GET /api/v1/admin/domain_permission_subscriptions/{id} domainPermissionSubscriptionGet +// +// Get domain permission subscription with the given ID. +// +// --- +// tags: +// - admin +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// required: true +// in: path +// description: ID of the domain permission subscription. +// type: string +// +// security: +// - OAuth2 Bearer: +// - admin +// +// responses: +// '200': +// description: Domain permission subscription. +// schema: +// "$ref": "#/definitions/domainPermissionSubscription" +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) DomainPermissionSubscriptionGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if !*authed.User.Admin { + err := fmt.Errorf("user %s not an admin", authed.User.ID) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if authed.Account.IsMoving() { + apiutil.ForbiddenAfterMove(c) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + permSub, errWithCode := m.processor.Admin().DomainPermissionSubscriptionGet(c.Request.Context(), id) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, permSub) +} diff --git a/internal/api/client/admin/domainpermissionsubscriptionremove.go b/internal/api/client/admin/domainpermissionsubscriptionremove.go new file mode 100644 index 000000000..97f226a31 --- /dev/null +++ b/internal/api/client/admin/domainpermissionsubscriptionremove.go @@ -0,0 +1,143 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package admin + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +// DomainPermissionSubscriptionRemovePOSTHandler swagger:operation POST /api/v1/admin/domain_permission_subscriptions/{id}/remove domainPermissionSubscriptionRemove +// +// Remove a domain permission subscription. +// +// --- +// tags: +// - admin +// +// consumes: +// - multipart/form-data +// - application/json +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// required: true +// in: path +// description: ID of the domain permission subscription. +// type: string +// - +// name: remove_children +// in: formData +// description: >- +// When removing the domain permission subscription, also +// remove children of this subscription, ie., domain permissions +// that are managed by this subscription. If false, then children +// will instead be orphaned but not removed. +// +// Note that removed permissions may end up being created again later +// by another domain permission subscription of lower priority than +// the removed subscription. Likewise, orphaned children may be later +// adopted by another subscription. +// type: boolean +// default: true +// +// security: +// - OAuth2 Bearer: +// - admin +// +// responses: +// '200': +// description: The removed domain permission subscription. +// schema: +// "$ref": "#/definitions/domainPermissionSubscription" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '406': +// description: not acceptable +// '409': +// description: conflict +// '500': +// description: internal server error +func (m *Module) DomainPermissionSubscriptionRemovePOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if !*authed.User.Admin { + err := fmt.Errorf("user %s not an admin", authed.User.ID) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if authed.Account.IsMoving() { + apiutil.ForbiddenAfterMove(c) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + type RemoveForm struct { + RemoveChildren *bool `json:"remove_children" form:"remove_children"` + } + + form := new(RemoveForm) + if err := c.ShouldBind(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + return + } + + // Default removeChildren to true. + removeChildren := util.PtrOrValue(form.RemoveChildren, true) + + permSub, errWithCode := m.processor.Admin().DomainPermissionSubscriptionRemove( + c.Request.Context(), + id, + removeChildren, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, permSub) +} diff --git a/internal/api/client/admin/domainpermissionsubscriptionsget.go b/internal/api/client/admin/domainpermissionsubscriptionsget.go new file mode 100644 index 000000000..477013ec9 --- /dev/null +++ b/internal/api/client/admin/domainpermissionsubscriptionsget.go @@ -0,0 +1,177 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package admin + +import ( + "errors" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/paging" +) + +// DomainPermissionSubscriptionsGETHandler swagger:operation GET /api/v1/admin/domain_permission_subscriptions domainPermissionSubscriptionsGet +// +// View domain permission subscriptions. +// +// The subscriptions will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer). +// +// The next and previous queries can be parsed from the returned Link header. +// +// Example: +// +// ``` +// ; rel="next", ; rel="prev" +// ```` +// +// --- +// tags: +// - admin +// +// produces: +// - application/json +// +// parameters: +// - +// name: permission_type +// type: string +// description: Filter on "block" or "allow" type subscriptions. +// in: query +// - +// name: max_id +// type: string +// description: >- +// Return only items *OLDER* than the given max ID (for paging downwards). +// The item with the specified ID will not be included in the response. +// in: query +// - +// name: since_id +// type: string +// description: >- +// Return only items *NEWER* than the given since ID. +// The item with the specified ID will not be included in the response. +// in: query +// - +// name: min_id +// type: string +// description: >- +// Return only items immediately *NEWER* than the given min ID (for paging upwards). +// The item with the specified ID will not be included in the response. +// in: query +// - +// name: limit +// type: integer +// description: Number of items to return. +// default: 20 +// minimum: 1 +// maximum: 100 +// in: query +// +// security: +// - OAuth2 Bearer: +// - admin +// +// responses: +// '200': +// description: Domain permission subscriptions. +// schema: +// type: array +// items: +// "$ref": "#/definitions/domainPermissionSubscription" +// headers: +// Link: +// type: string +// description: Links to the next and previous queries. +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) DomainPermissionSubscriptionsGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if !*authed.User.Admin { + err := fmt.Errorf("user %s not an admin", authed.User.ID) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if authed.Account.IsMoving() { + apiutil.ForbiddenAfterMove(c) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + permType := c.Query(apiutil.DomainPermissionPermTypeKey) + switch permType { + case "", "block", "allow": + // No problem. + + default: + // Invalid. + text := fmt.Sprintf( + "permission_type %s not recognized, valid values are empty string, block, or allow", + permType, + ) + errWithCode := gtserror.NewErrorBadRequest(errors.New(text), text) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + page, errWithCode := paging.ParseIDPage(c, 1, 200, 20) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + resp, errWithCode := m.processor.Admin().DomainPermissionSubscriptionsGet( + c.Request.Context(), + gtsmodel.ParseDomainPermissionType(permType), + page, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + if resp.LinkHeader != "" { + c.Header("Link", resp.LinkHeader) + } + + apiutil.JSON(c, http.StatusOK, resp.Items) +} diff --git a/internal/api/client/admin/domainpermissionsubscriptionspreviewget.go b/internal/api/client/admin/domainpermissionsubscriptionspreviewget.go new file mode 100644 index 000000000..7b5bf7fb7 --- /dev/null +++ b/internal/api/client/admin/domainpermissionsubscriptionspreviewget.go @@ -0,0 +1,135 @@ +// 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" +) + +// DomainPermissionSubscriptionsPreviewGETHandler swagger:operation GET /api/v1/admin/domain_permission_subscriptions/preview domainPermissionSubscriptionsPreviewGet +// +// View all domain permission subscriptions of the given permission type, in priority order (highest to lowest). +// +// This view allows you to see the order in which domain permissions will actually be fetched and created. +// +// --- +// tags: +// - admin +// +// produces: +// - application/json +// +// parameters: +// - +// name: permission_type +// type: string +// description: Filter on "block" or "allow" type subscriptions. +// in: query +// required: true +// +// security: +// - OAuth2 Bearer: +// - admin +// +// responses: +// '200': +// description: Domain permission subscriptions. +// schema: +// type: array +// items: +// "$ref": "#/definitions/domainPermissionSubscription" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) DomainPermissionSubscriptionsPreviewGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if !*authed.User.Admin { + err := fmt.Errorf("user %s not an admin", authed.User.ID) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if authed.Account.IsMoving() { + apiutil.ForbiddenAfterMove(c) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + permType := c.Query(apiutil.DomainPermissionPermTypeKey) + switch permType { + case "block", "allow": + // No problem. + + case "": + // Not set. + text := fmt.Sprintf( + "permission_type must be set, valid values are block or allow", + permType, + ) + errWithCode := gtserror.NewErrorBadRequest(errors.New(text), text) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + + default: + // Invalid. + text := fmt.Sprintf( + "permission_type %s not recognized, valid values are block or allow", + permType, + ) + errWithCode := gtserror.NewErrorBadRequest(errors.New(text), text) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + resp, errWithCode := m.processor.Admin().DomainPermissionSubscriptionsGetByPriority( + c.Request.Context(), + gtsmodel.ParseDomainPermissionType(permType), + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, resp) +} diff --git a/internal/api/client/admin/domainpermissionsubscriptionupdate.go b/internal/api/client/admin/domainpermissionsubscriptionupdate.go new file mode 100644 index 000000000..d436a0411 --- /dev/null +++ b/internal/api/client/admin/domainpermissionsubscriptionupdate.go @@ -0,0 +1,255 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package admin + +import ( + "errors" + "fmt" + "net/http" + "net/url" + + "github.com/gin-gonic/gin" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +// DomainPermissionSubscriptionPATCHHandler swagger:operation PATCH /api/v1/admin/domain_permission_subscriptions/${id} domainPermissionSubscriptionUpdate +// +// Update a domain permission subscription with the given parameters. +// +// --- +// tags: +// - admin +// +// consumes: +// - multipart/form-data +// - application/json +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// required: true +// in: path +// description: ID of the domain permission subscription. +// type: string +// - +// name: priority +// in: formData +// description: >- +// Priority of this subscription compared to others of the same permission type. +// 0-255 (higher = higher priority). Higher priority subscriptions will overwrite +// permissions generated by lower priority subscriptions. When two subscriptions +// have the same `priority` value, priority is indeterminate, so it's recommended +// to always set this value manually. +// type: number +// minimum: 0 +// maximum: 255 +// - +// name: title +// in: formData +// description: Optional title for this subscription. +// type: string +// - +// name: uri +// in: formData +// description: URI to call in order to fetch the permissions list. +// type: string +// - +// name: as_draft +// in: formData +// description: >- +// If true, domain permissions arising from this subscription will be +// created as drafts that must be approved by a moderator to take effect. +// If false, domain permissions from this subscription will come into force immediately. +// Defaults to "true". +// type: boolean +// default: true +// - +// name: adopt_orphans +// in: formData +// description: >- +// If true, this domain permission subscription will "adopt" domain permissions +// which already exist on the instance, and which meet the following conditions: +// 1) they have no subscription ID (ie., they're "orphaned") and 2) they are present +// in the subscribed list. Such orphaned domain permissions will be given this +// subscription's subscription ID value and be managed by this subscription. +// type: boolean +// default: false +// - +// name: content_type +// in: formData +// description: >- +// MIME content type to use when parsing the permissions list. +// One of "text/plain", "text/csv", and "application/json". +// type: string +// - +// name: fetch_username +// in: formData +// description: >- +// Optional basic auth username to provide when fetching given uri. +// If set, will be transmitted along with `fetch_password` when doing the fetch. +// type: string +// - +// name: fetch_password +// in: formData +// description: >- +// Optional basic auth password to provide when fetching given uri. +// If set, will be transmitted along with `fetch_username` when doing the fetch. +// type: string +// +// security: +// - OAuth2 Bearer: +// - admin +// +// responses: +// '200': +// description: The updated domain permission subscription. +// schema: +// "$ref": "#/definitions/domainPermissionSubscription" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '406': +// description: not acceptable +// '409': +// description: conflict +// '500': +// description: internal server error +func (m *Module) DomainPermissionSubscriptionPATCHHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if !*authed.User.Admin { + err := fmt.Errorf("user %s not an admin", authed.User.ID) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if authed.Account.IsMoving() { + apiutil.ForbiddenAfterMove(c) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + // Parse + validate form. + form := new(apimodel.DomainPermissionSubscriptionRequest) + if err := c.ShouldBind(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + return + } + + // Normalize priority if set. + var priority *uint8 + if form.Priority != nil { + prioInt := *form.Priority + if prioInt < 0 || prioInt > 255 { + const errText = "priority must be a number in the range 0 to 255" + errWithCode := gtserror.NewErrorBadRequest(errors.New(errText), errText) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + priority = util.Ptr(uint8(prioInt)) // #nosec G115 -- Just validated. + } + + // Validate URI if set. + var uriStr *string + if form.URI != nil { + uri, err := url.Parse(*form.URI) + if err != nil { + err := fmt.Errorf("invalid uri provided: %w", err) + errWithCode := gtserror.NewErrorBadRequest(err, err.Error()) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + // Normalize URI by converting back to string. + uriStr = util.Ptr(uri.String()) + } + + // Validate content type if set. + var contentType *gtsmodel.DomainPermSubContentType + if form.ContentType != nil { + ct, errWithCode := parseDomainPermSubContentType(*form.ContentType) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + contentType = &ct + fmt.Println(*contentType) + } + + // Make sure at least one field is set, + // otherwise we're trying to update nothing. + if priority == nil && + form.Title == nil && + uriStr == nil && + contentType == nil && + form.AsDraft == nil && + form.AdoptOrphans == nil && + form.FetchUsername == nil && + form.FetchPassword == nil { + const errText = "no updateable fields set on request" + errWithCode := gtserror.NewErrorBadRequest(errors.New(errText), errText) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + permSub, errWithCode := m.processor.Admin().DomainPermissionSubscriptionUpdate( + c.Request.Context(), + id, + priority, + form.Title, + uriStr, + contentType, + form.AsDraft, + form.AdoptOrphans, + form.FetchUsername, + form.FetchPassword, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, permSub) +} diff --git a/internal/api/model/domain.go b/internal/api/model/domain.go index c973c7d92..94a190f63 100644 --- a/internal/api/model/domain.go +++ b/internal/api/model/domain.go @@ -99,3 +99,101 @@ type DomainKeysExpireRequest struct { // hostname/domain to expire keys for. Domain string `form:"domain" json:"domain"` } + +// DomainPermissionSubscription represents an auto-refreshing subscription to a list of domain permissions (allows, blocks). +// +// swagger:model domainPermissionSubscription +type DomainPermissionSubscription struct { + // The ID of the domain permission subscription. + // example: 01FBW21XJA09XYX51KV5JVBW0F + // readonly: true + ID string `json:"id"` + // Priority of this subscription compared to others of the same permission type. 0-255 (higher = higher priority). + // example: 100 + Priority uint8 `json:"priority"` + // Title of this subscription, as set by admin who created or updated it. + // example: really cool list of neato pals + Title string `json:"title"` + // The type of domain permission subscription (allow, block). + // example: block + PermissionType string `json:"permission_type"` + // If true, domain permissions arising from this subscription will be created as drafts that must be approved by a moderator to take effect. If false, domain permissions from this subscription will come into force immediately. + // example: true + AsDraft bool `json:"as_draft"` + // If true, this domain permission subscription will "adopt" domain permissions which already exist on the instance, and which meet the following conditions: 1) they have no subscription ID (ie., they're "orphaned") and 2) they are present in the subscribed list. Such orphaned domain permissions will be given this subscription's subscription ID value. + // example: false + AdoptOrphans bool `json:"adopt_orphans"` + // Time at which the subscription was created (ISO 8601 Datetime). + // example: 2021-07-30T09:20:25+00:00 + CreatedAt string `json:"created_at"` + // ID of the account that created this subscription. + // example: 01FBW21XJA09XYX51KV5JVBW0F + // readonly: true + CreatedBy string `json:"created_by"` + // URI to call in order to fetch the permissions list. + // example: https://www.example.org/blocklists/list1.csv + URI string `json:"uri"` + // MIME content type to use when parsing the permissions list. + // example: text/csv + ContentType string `json:"content_type"` + // (Optional) username to set for basic auth when doing a fetch of URI. + // example: admin123 + FetchUsername string `json:"fetch_username,omitempty"` + // (Optional) password to set for basic auth when doing a fetch of URI. + // example: admin123 + FetchPassword string `json:"fetch_password,omitempty"` + // Time of the most recent fetch attempt (successful or otherwise) (ISO 8601 Datetime). + // example: 2021-07-30T09:20:25+00:00 + // readonly: true + FetchedAt string `json:"fetched_at,omitempty"` + // Time of the most recent successful fetch (ISO 8601 Datetime). + // example: 2021-07-30T09:20:25+00:00 + // readonly: true + SuccessfullyFetchedAt string `json:"successfully_fetched_at,omitempty"` + // If most recent fetch attempt failed, this field will contain an error message related to the fetch attempt. + // example: Oopsie doopsie, we made a fucky wucky. + // readonly: true + Error string `json:"error,omitempty"` + // Count of domain permission entries discovered at URI on last (successful) fetch. + // example: 53 + // readonly: true + Count uint64 `json:"count"` +} + +// DomainPermissionSubscriptionRequest represents a request to create or update a domain permission subscription.. +// +// swagger:ignore +type DomainPermissionSubscriptionRequest struct { + // Priority of this subscription compared to others of the same permission type. 0-255 (higher = higher priority). + // example: 100 + Priority *int `form:"priority" json:"priority"` + // Title of this subscription, as set by admin who created or updated it. + // example: really cool list of neato pals + Title *string `form:"title" json:"title"` + // The type of domain permission subscription (allow, block). + // example: block + PermissionType *string `form:"permission_type" json:"permission_type"` + // URI to call in order to fetch the permissions list. + // example: https://www.example.org/blocklists/list1.csv + URI *string `form:"uri" json:"uri"` + // MIME content type to use when parsing the permissions list. + // example: text/csv + ContentType *string `form:"content_type" json:"content_type"` + // If true, domain permissions arising from this subscription will be + // created as drafts that must be approved by a moderator to take effect. + // If false, domain permissions from this subscription will come into force immediately. + // example: true + AsDraft *bool `form:"as_draft" json:"as_draft"` + // If true, this domain permission subscription will "adopt" domain permissions + // which already exist on the instance, and which meet the following conditions: + // 1) they have no subscription ID (ie., they're "orphaned") and 2) they are present + // in the subscribed list. Such orphaned domain permissions will be given this + // subscription's subscription ID value and be managed by this subscription. + AdoptOrphans *bool `form:"adopt_orphans" json:"adopt_orphans"` + // (Optional) username to set for basic auth when doing a fetch of URI. + // example: admin123 + FetchUsername *string `form:"fetch_username" json:"fetch_username"` + // (Optional) password to set for basic auth when doing a fetch of URI. + // example: admin123 + FetchPassword *string `form:"fetch_password" json:"fetch_password"` +} diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 1a66fcd6b..560fbc9f6 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -75,6 +75,7 @@ func (c *Caches) Init() { c.initDomainAllow() c.initDomainBlock() c.initDomainPermissionDraft() + c.initDomainPermissionSubscription() c.initDomainPermissionExclude() c.initEmoji() c.initEmojiCategory() diff --git a/internal/cache/db.go b/internal/cache/db.go index dc47bc31c..35e90ced2 100644 --- a/internal/cache/db.go +++ b/internal/cache/db.go @@ -70,6 +70,9 @@ type DBCaches struct { // DomainPermissionDraft provides access to the domain permission draft database cache. DomainPermissionDraft StructCache[*gtsmodel.DomainPermissionDraft] + // DomainPermissionSubscription provides access to the domain permission subscription database cache. + DomainPermissionSubscription StructCache[*gtsmodel.DomainPermissionSubscription] + // DomainPermissionExclude provides access to the domain permission exclude database cache. DomainPermissionExclude *domain.Cache @@ -589,6 +592,37 @@ func (c *Caches) initDomainPermissionDraft() { }) } +func (c *Caches) initDomainPermissionSubscription() { + // Calculate maximum cache size. + cap := calculateResultCacheMax( + sizeofDomainPermissionSubscription(), // model in-mem size. + config.GetCacheDomainPermissionSubscriptionMemRation(), + ) + + log.Infof(nil, "cache size = %d", cap) + + copyF := func(d1 *gtsmodel.DomainPermissionSubscription) *gtsmodel.DomainPermissionSubscription { + d2 := new(gtsmodel.DomainPermissionSubscription) + *d2 = *d1 + + // Don't include ptr fields that + // will be populated separately. + d2.CreatedByAccount = nil + + return d2 + } + + c.DB.DomainPermissionSubscription.Init(structr.CacheConfig[*gtsmodel.DomainPermissionSubscription]{ + Indices: []structr.IndexConfig{ + {Fields: "ID"}, + {Fields: "URI"}, + }, + MaxSize: cap, + IgnoreErr: ignoreErrors, + Copy: copyF, + }) +} + func (c *Caches) initDomainPermissionExclude() { c.DB.DomainPermissionExclude = new(domain.Cache) } diff --git a/internal/cache/size.go b/internal/cache/size.go index 988755099..49cc172df 100644 --- a/internal/cache/size.go +++ b/internal/cache/size.go @@ -357,6 +357,26 @@ func sizeofDomainPermissionDraft() uintptr { })) } +func sizeofDomainPermissionSubscription() uintptr { + return uintptr(size.Of(>smodel.DomainPermissionSubscription{ + ID: exampleID, + Priority: uint8(255), + Title: exampleTextSmall, + PermissionType: gtsmodel.DomainPermissionBlock, + AsDraft: util.Ptr(true), + CreatedByAccountID: exampleID, + URI: exampleURI, + ContentType: gtsmodel.DomainPermSubContentTypeCSV, + FetchUsername: "username", + FetchPassword: "password", + FetchedAt: exampleTime, + SuccessfullyFetchedAt: exampleTime, + ETag: exampleID, + Error: exampleTextSmall, + Count: 100, + })) +} + func sizeofEmoji() uintptr { return uintptr(size.Of(>smodel.Emoji{ ID: exampleID, diff --git a/internal/config/config.go b/internal/config/config.go index 413743409..2bf2a77ad 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -196,60 +196,61 @@ type HTTPClientConfiguration struct { } type CacheConfiguration struct { - MemoryTarget bytesize.Size `name:"memory-target"` - AccountMemRatio float64 `name:"account-mem-ratio"` - AccountNoteMemRatio float64 `name:"account-note-mem-ratio"` - AccountSettingsMemRatio float64 `name:"account-settings-mem-ratio"` - AccountStatsMemRatio float64 `name:"account-stats-mem-ratio"` - ApplicationMemRatio float64 `name:"application-mem-ratio"` - BlockMemRatio float64 `name:"block-mem-ratio"` - BlockIDsMemRatio float64 `name:"block-ids-mem-ratio"` - BoostOfIDsMemRatio float64 `name:"boost-of-ids-mem-ratio"` - ClientMemRatio float64 `name:"client-mem-ratio"` - ConversationMemRatio float64 `name:"conversation-mem-ratio"` - ConversationLastStatusIDsMemRatio float64 `name:"conversation-last-status-ids-mem-ratio"` - DomainPermissionDraftMemRation float64 `name:"domain-permission-draft-mem-ratio"` - EmojiMemRatio float64 `name:"emoji-mem-ratio"` - EmojiCategoryMemRatio float64 `name:"emoji-category-mem-ratio"` - FilterMemRatio float64 `name:"filter-mem-ratio"` - FilterKeywordMemRatio float64 `name:"filter-keyword-mem-ratio"` - FilterStatusMemRatio float64 `name:"filter-status-mem-ratio"` - FollowMemRatio float64 `name:"follow-mem-ratio"` - FollowIDsMemRatio float64 `name:"follow-ids-mem-ratio"` - FollowRequestMemRatio float64 `name:"follow-request-mem-ratio"` - FollowRequestIDsMemRatio float64 `name:"follow-request-ids-mem-ratio"` - FollowingTagIDsMemRatio float64 `name:"following-tag-ids-mem-ratio"` - InReplyToIDsMemRatio float64 `name:"in-reply-to-ids-mem-ratio"` - InstanceMemRatio float64 `name:"instance-mem-ratio"` - InteractionRequestMemRatio float64 `name:"interaction-request-mem-ratio"` - ListMemRatio float64 `name:"list-mem-ratio"` - ListIDsMemRatio float64 `name:"list-ids-mem-ratio"` - ListedIDsMemRatio float64 `name:"listed-ids-mem-ratio"` - MarkerMemRatio float64 `name:"marker-mem-ratio"` - MediaMemRatio float64 `name:"media-mem-ratio"` - MentionMemRatio float64 `name:"mention-mem-ratio"` - MoveMemRatio float64 `name:"move-mem-ratio"` - NotificationMemRatio float64 `name:"notification-mem-ratio"` - PollMemRatio float64 `name:"poll-mem-ratio"` - PollVoteMemRatio float64 `name:"poll-vote-mem-ratio"` - PollVoteIDsMemRatio float64 `name:"poll-vote-ids-mem-ratio"` - ReportMemRatio float64 `name:"report-mem-ratio"` - SinBinStatusMemRatio float64 `name:"sin-bin-status-mem-ratio"` - StatusMemRatio float64 `name:"status-mem-ratio"` - StatusBookmarkMemRatio float64 `name:"status-bookmark-mem-ratio"` - StatusBookmarkIDsMemRatio float64 `name:"status-bookmark-ids-mem-ratio"` - StatusEditMemRatio float64 `name:"status-edit-mem-ratio"` - StatusFaveMemRatio float64 `name:"status-fave-mem-ratio"` - StatusFaveIDsMemRatio float64 `name:"status-fave-ids-mem-ratio"` - TagMemRatio float64 `name:"tag-mem-ratio"` - ThreadMuteMemRatio float64 `name:"thread-mute-mem-ratio"` - TokenMemRatio float64 `name:"token-mem-ratio"` - TombstoneMemRatio float64 `name:"tombstone-mem-ratio"` - UserMemRatio float64 `name:"user-mem-ratio"` - UserMuteMemRatio float64 `name:"user-mute-mem-ratio"` - UserMuteIDsMemRatio float64 `name:"user-mute-ids-mem-ratio"` - WebfingerMemRatio float64 `name:"webfinger-mem-ratio"` - VisibilityMemRatio float64 `name:"visibility-mem-ratio"` + MemoryTarget bytesize.Size `name:"memory-target"` + AccountMemRatio float64 `name:"account-mem-ratio"` + AccountNoteMemRatio float64 `name:"account-note-mem-ratio"` + AccountSettingsMemRatio float64 `name:"account-settings-mem-ratio"` + AccountStatsMemRatio float64 `name:"account-stats-mem-ratio"` + ApplicationMemRatio float64 `name:"application-mem-ratio"` + BlockMemRatio float64 `name:"block-mem-ratio"` + BlockIDsMemRatio float64 `name:"block-ids-mem-ratio"` + BoostOfIDsMemRatio float64 `name:"boost-of-ids-mem-ratio"` + ClientMemRatio float64 `name:"client-mem-ratio"` + ConversationMemRatio float64 `name:"conversation-mem-ratio"` + ConversationLastStatusIDsMemRatio float64 `name:"conversation-last-status-ids-mem-ratio"` + DomainPermissionDraftMemRation float64 `name:"domain-permission-draft-mem-ratio"` + DomainPermissionSubscriptionMemRation float64 `name:"domain-permission-subscription-mem-ratio"` + EmojiMemRatio float64 `name:"emoji-mem-ratio"` + EmojiCategoryMemRatio float64 `name:"emoji-category-mem-ratio"` + FilterMemRatio float64 `name:"filter-mem-ratio"` + FilterKeywordMemRatio float64 `name:"filter-keyword-mem-ratio"` + FilterStatusMemRatio float64 `name:"filter-status-mem-ratio"` + FollowMemRatio float64 `name:"follow-mem-ratio"` + FollowIDsMemRatio float64 `name:"follow-ids-mem-ratio"` + FollowRequestMemRatio float64 `name:"follow-request-mem-ratio"` + FollowRequestIDsMemRatio float64 `name:"follow-request-ids-mem-ratio"` + FollowingTagIDsMemRatio float64 `name:"following-tag-ids-mem-ratio"` + InReplyToIDsMemRatio float64 `name:"in-reply-to-ids-mem-ratio"` + InstanceMemRatio float64 `name:"instance-mem-ratio"` + InteractionRequestMemRatio float64 `name:"interaction-request-mem-ratio"` + ListMemRatio float64 `name:"list-mem-ratio"` + ListIDsMemRatio float64 `name:"list-ids-mem-ratio"` + ListedIDsMemRatio float64 `name:"listed-ids-mem-ratio"` + MarkerMemRatio float64 `name:"marker-mem-ratio"` + MediaMemRatio float64 `name:"media-mem-ratio"` + MentionMemRatio float64 `name:"mention-mem-ratio"` + MoveMemRatio float64 `name:"move-mem-ratio"` + NotificationMemRatio float64 `name:"notification-mem-ratio"` + PollMemRatio float64 `name:"poll-mem-ratio"` + PollVoteMemRatio float64 `name:"poll-vote-mem-ratio"` + PollVoteIDsMemRatio float64 `name:"poll-vote-ids-mem-ratio"` + ReportMemRatio float64 `name:"report-mem-ratio"` + SinBinStatusMemRatio float64 `name:"sin-bin-status-mem-ratio"` + StatusMemRatio float64 `name:"status-mem-ratio"` + StatusBookmarkMemRatio float64 `name:"status-bookmark-mem-ratio"` + StatusBookmarkIDsMemRatio float64 `name:"status-bookmark-ids-mem-ratio"` + StatusEditMemRatio float64 `name:"status-edit-mem-ratio"` + StatusFaveMemRatio float64 `name:"status-fave-mem-ratio"` + StatusFaveIDsMemRatio float64 `name:"status-fave-ids-mem-ratio"` + TagMemRatio float64 `name:"tag-mem-ratio"` + ThreadMuteMemRatio float64 `name:"thread-mute-mem-ratio"` + TokenMemRatio float64 `name:"token-mem-ratio"` + TombstoneMemRatio float64 `name:"tombstone-mem-ratio"` + UserMemRatio float64 `name:"user-mem-ratio"` + UserMuteMemRatio float64 `name:"user-mute-mem-ratio"` + UserMuteIDsMemRatio float64 `name:"user-mute-ids-mem-ratio"` + WebfingerMemRatio float64 `name:"webfinger-mem-ratio"` + VisibilityMemRatio float64 `name:"visibility-mem-ratio"` } // MarshalMap will marshal current Configuration into a map structure (useful for JSON/TOML/YAML). diff --git a/internal/config/defaults.go b/internal/config/defaults.go index f77c5c456..97d96d1ba 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -158,59 +158,60 @@ // when TODO items in the size.go source // file have been addressed, these should // be able to make some more sense :D - AccountMemRatio: 5, - AccountNoteMemRatio: 1, - AccountSettingsMemRatio: 0.1, - AccountStatsMemRatio: 2, - ApplicationMemRatio: 0.1, - BlockMemRatio: 2, - BlockIDsMemRatio: 3, - BoostOfIDsMemRatio: 3, - ClientMemRatio: 0.1, - ConversationMemRatio: 1, - ConversationLastStatusIDsMemRatio: 2, - DomainPermissionDraftMemRation: 0.5, - EmojiMemRatio: 3, - EmojiCategoryMemRatio: 0.1, - FilterMemRatio: 0.5, - FilterKeywordMemRatio: 0.5, - FilterStatusMemRatio: 0.5, - FollowMemRatio: 2, - FollowIDsMemRatio: 4, - FollowRequestMemRatio: 2, - FollowRequestIDsMemRatio: 2, - FollowingTagIDsMemRatio: 2, - InReplyToIDsMemRatio: 3, - InstanceMemRatio: 1, - InteractionRequestMemRatio: 1, - ListMemRatio: 1, - ListIDsMemRatio: 2, - ListedIDsMemRatio: 2, - MarkerMemRatio: 0.5, - MediaMemRatio: 4, - MentionMemRatio: 2, - MoveMemRatio: 0.1, - NotificationMemRatio: 2, - PollMemRatio: 1, - PollVoteMemRatio: 2, - PollVoteIDsMemRatio: 2, - ReportMemRatio: 1, - SinBinStatusMemRatio: 0.5, - StatusMemRatio: 5, - StatusBookmarkMemRatio: 0.5, - StatusBookmarkIDsMemRatio: 2, - StatusEditMemRatio: 2, - StatusFaveMemRatio: 2, - StatusFaveIDsMemRatio: 3, - TagMemRatio: 2, - ThreadMuteMemRatio: 0.2, - TokenMemRatio: 0.75, - TombstoneMemRatio: 0.5, - UserMemRatio: 0.25, - UserMuteMemRatio: 2, - UserMuteIDsMemRatio: 3, - WebfingerMemRatio: 0.1, - VisibilityMemRatio: 2, + AccountMemRatio: 5, + AccountNoteMemRatio: 1, + AccountSettingsMemRatio: 0.1, + AccountStatsMemRatio: 2, + ApplicationMemRatio: 0.1, + BlockMemRatio: 2, + BlockIDsMemRatio: 3, + BoostOfIDsMemRatio: 3, + ClientMemRatio: 0.1, + ConversationMemRatio: 1, + ConversationLastStatusIDsMemRatio: 2, + DomainPermissionDraftMemRation: 0.5, + DomainPermissionSubscriptionMemRation: 0.5, + EmojiMemRatio: 3, + EmojiCategoryMemRatio: 0.1, + FilterMemRatio: 0.5, + FilterKeywordMemRatio: 0.5, + FilterStatusMemRatio: 0.5, + FollowMemRatio: 2, + FollowIDsMemRatio: 4, + FollowRequestMemRatio: 2, + FollowRequestIDsMemRatio: 2, + FollowingTagIDsMemRatio: 2, + InReplyToIDsMemRatio: 3, + InstanceMemRatio: 1, + InteractionRequestMemRatio: 1, + ListMemRatio: 1, + ListIDsMemRatio: 2, + ListedIDsMemRatio: 2, + MarkerMemRatio: 0.5, + MediaMemRatio: 4, + MentionMemRatio: 2, + MoveMemRatio: 0.1, + NotificationMemRatio: 2, + PollMemRatio: 1, + PollVoteMemRatio: 2, + PollVoteIDsMemRatio: 2, + ReportMemRatio: 1, + SinBinStatusMemRatio: 0.5, + StatusMemRatio: 5, + StatusBookmarkMemRatio: 0.5, + StatusBookmarkIDsMemRatio: 2, + StatusEditMemRatio: 2, + StatusFaveMemRatio: 2, + StatusFaveIDsMemRatio: 3, + TagMemRatio: 2, + ThreadMuteMemRatio: 0.2, + TokenMemRatio: 0.75, + TombstoneMemRatio: 0.5, + UserMemRatio: 0.25, + UserMuteMemRatio: 2, + UserMuteIDsMemRatio: 3, + WebfingerMemRatio: 0.1, + VisibilityMemRatio: 2, }, HTTPClient: HTTPClientConfiguration{ diff --git a/internal/config/helpers.gen.go b/internal/config/helpers.gen.go index 543292ebe..625c4ea78 100644 --- a/internal/config/helpers.gen.go +++ b/internal/config/helpers.gen.go @@ -3187,6 +3187,37 @@ func SetCacheDomainPermissionDraftMemRation(v float64) { global.SetCacheDomainPermissionDraftMemRation(v) } +// GetCacheDomainPermissionSubscriptionMemRation safely fetches the Configuration value for state's 'Cache.DomainPermissionSubscriptionMemRation' field +func (st *ConfigState) GetCacheDomainPermissionSubscriptionMemRation() (v float64) { + st.mutex.RLock() + v = st.config.Cache.DomainPermissionSubscriptionMemRation + st.mutex.RUnlock() + return +} + +// SetCacheDomainPermissionSubscriptionMemRation safely sets the Configuration value for state's 'Cache.DomainPermissionSubscriptionMemRation' field +func (st *ConfigState) SetCacheDomainPermissionSubscriptionMemRation(v float64) { + st.mutex.Lock() + defer st.mutex.Unlock() + st.config.Cache.DomainPermissionSubscriptionMemRation = v + st.reloadToViper() +} + +// CacheDomainPermissionSubscriptionMemRationFlag returns the flag name for the 'Cache.DomainPermissionSubscriptionMemRation' field +func CacheDomainPermissionSubscriptionMemRationFlag() string { + return "cache-domain-permission-subscription-mem-ratio" +} + +// GetCacheDomainPermissionSubscriptionMemRation safely fetches the value for global configuration 'Cache.DomainPermissionSubscriptionMemRation' field +func GetCacheDomainPermissionSubscriptionMemRation() float64 { + return global.GetCacheDomainPermissionSubscriptionMemRation() +} + +// SetCacheDomainPermissionSubscriptionMemRation safely sets the value for global configuration 'Cache.DomainPermissionSubscriptionMemRation' field +func SetCacheDomainPermissionSubscriptionMemRation(v float64) { + global.SetCacheDomainPermissionSubscriptionMemRation(v) +} + // GetCacheEmojiMemRatio safely fetches the Configuration value for state's 'Cache.EmojiMemRatio' field func (st *ConfigState) GetCacheEmojiMemRatio() (v float64) { st.mutex.RLock() diff --git a/internal/db/bundb/domainpermissionsubscription.go b/internal/db/bundb/domainpermissionsubscription.go new file mode 100644 index 000000000..fb2c94be6 --- /dev/null +++ b/internal/db/bundb/domainpermissionsubscription.go @@ -0,0 +1,320 @@ +// 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) GetDomainPermissionSubscriptionsByPriority( + ctx context.Context, + permType gtsmodel.DomainPermissionType, +) ( + []*gtsmodel.DomainPermissionSubscription, + error, +) { + permSubIDs := []string{} + + q := d.db. + NewSelect(). + TableExpr( + "? AS ?", + bun.Ident("domain_permission_subscriptions"), + bun.Ident("domain_permission_subscription"), + ). + // Select only IDs from table + Column("domain_permission_subscription.id"). + // Select only subs of given perm type. + Where( + "? = ?", + bun.Ident("domain_permission_subscription.permission_type"), + permType, + ). + // Order by priority descending. + OrderExpr( + "? DESC", + bun.Ident("domain_permission_subscription.priority"), + ) + + if err := q.Scan(ctx, &permSubIDs); err != nil { + return nil, err + } + + // Catch case of no items early + if len(permSubIDs) == 0 { + return nil, db.ErrNoEntries + } + + // Allocate return slice (will be at most len permSubIDs). + permSubs := make([]*gtsmodel.DomainPermissionSubscription, 0, len(permSubIDs)) + for _, id := range permSubIDs { + permSub, err := d.GetDomainPermissionSubscriptionByID(ctx, id) + if err != nil { + log.Errorf(ctx, "error getting domain permission subscription %q: %v", id, err) + continue + } + + // Append to return slice + permSubs = append(permSubs, permSub) + } + + return permSubs, nil +} + +func (d *domainDB) PutDomainPermissionSubscription( + ctx context.Context, + permSubscription *gtsmodel.DomainPermissionSubscription, +) error { + return d.state.Caches.DB.DomainPermissionSubscription.Store( + permSubscription, + func() error { + _, err := d.db. + NewInsert(). + Model(permSubscription). + Exec(ctx) + return err + }, + ) +} + +func (d *domainDB) UpdateDomainPermissionSubscription( + ctx context.Context, + permSubscription *gtsmodel.DomainPermissionSubscription, + columns ...string, +) error { + return d.state.Caches.DB.DomainPermissionSubscription.Store( + permSubscription, + func() error { + _, err := d.db. + NewUpdate(). + Model(permSubscription). + Where("? = ?", bun.Ident("id"), permSubscription.ID). + Column(columns...). + Exec(ctx) + return err + }, + ) +} + +func (d *domainDB) DeleteDomainPermissionSubscription( + ctx context.Context, + id string, +) error { + // Delete the permSub from DB. + q := d.db.NewDelete(). + TableExpr( + "? AS ?", + bun.Ident("domain_permission_subscriptions"), + bun.Ident("domain_permission_subscription"), + ). + Where( + "? = ?", + bun.Ident("domain_permission_subscription.id"), + id, + ) + + _, err := q.Exec(ctx) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return err + } + + // Invalidate any cached model by ID. + d.state.Caches.DB.DomainPermissionSubscription.Invalidate("ID", id) + + return nil +} 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..49b050d6d --- /dev/null +++ b/internal/db/bundb/migrations/20241022153016_domain_permission_subscriptions.go @@ -0,0 +1,75 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +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_subscriptions`. + if _, err := tx. + NewCreateTable(). + Model((*gtsmodel.DomainPermissionSubscription)(nil)). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + + // Create indexes. Indices. Indie sexes. + if _, err := tx. + NewCreateIndex(). + Table("domain_permission_subscriptions"). + // Filter on permission type. + Index("domain_permission_subscriptions_permission_type_idx"). + Column("permission_type"). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + + if _, err := tx. + NewCreateIndex(). + Table("domain_permission_subscriptions"). + // Sort by priority DESC. + Index("domain_permission_subscriptions_priority_order_idx"). + ColumnExpr("? DESC", bun.Ident("priority")). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + + return nil + }) + } + + down := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + return nil + }) + } + + if err := Migrations.Register(up, down); err != nil { + panic(err) + } +} diff --git a/internal/db/domain.go b/internal/db/domain.go index f4d05ad1d..535ecaf3e 100644 --- a/internal/db/domain.go +++ b/internal/db/domain.go @@ -132,4 +132,40 @@ type Domain interface { // IsDomainPermissionExcluded returns true if the given domain matches in the list of excluded domains. IsDomainPermissionExcluded(ctx context.Context, domain string) (bool, error) + + /* + Domain permission subscription stuff. + */ + + // GetDomainPermissionSubscriptionByID gets one DomainPermissionSubscription with the given ID. + GetDomainPermissionSubscriptionByID(ctx context.Context, id string) (*gtsmodel.DomainPermissionSubscription, error) + + // GetDomainPermissionSubscriptions returns a page of + // DomainPermissionSubscriptions using the given parameters. + GetDomainPermissionSubscriptions( + ctx context.Context, + permType gtsmodel.DomainPermissionType, + page *paging.Page, + ) ([]*gtsmodel.DomainPermissionSubscription, error) + + // GetDomainPermissionSubscriptionsByPriority returns *all* domain permission + // subscriptions of the given permission type, sorted by priority descending. + GetDomainPermissionSubscriptionsByPriority( + ctx context.Context, + permType gtsmodel.DomainPermissionType, + ) ([]*gtsmodel.DomainPermissionSubscription, error) + + // PutDomainPermissionSubscription stores one DomainPermissionSubscription. + PutDomainPermissionSubscription(ctx context.Context, permSub *gtsmodel.DomainPermissionSubscription) error + + // UpdateDomainPermissionSubscription updates the provided + // columns of one DomainPermissionSubscription. + UpdateDomainPermissionSubscription( + ctx context.Context, + permSub *gtsmodel.DomainPermissionSubscription, + columns ...string, + ) error + + // DeleteDomainPermissionSubscription deletes one DomainPermissionSubscription with the given id. + DeleteDomainPermissionSubscription(ctx context.Context, id string) error } diff --git a/internal/gtsmodel/domainpermissionsubscription.go b/internal/gtsmodel/domainpermissionsubscription.go new file mode 100644 index 000000000..2ef119e2f --- /dev/null +++ b/internal/gtsmodel/domainpermissionsubscription.go @@ -0,0 +1,75 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package gtsmodel + +import "time" + +type DomainPermissionSubscription struct { + ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // ID of this item in the database. + Priority uint8 `bun:""` // Priority of this subscription compared to others of the same permission type. 0-255 (higher = higher priority). + Title string `bun:",nullzero,unique"` // Moderator-set title for this list. + PermissionType DomainPermissionType `bun:",nullzero,notnull"` // Permission type of the subscription. + AsDraft *bool `bun:",nullzero,notnull,default:true"` // Create domain permission entries resulting from this subscription as drafts. + AdoptOrphans *bool `bun:",nullzero,notnull,default:false"` // Adopt orphaned domain permissions present in this subscription's entries. + CreatedByAccountID string `bun:"type:CHAR(26),nullzero,notnull"` // Account ID of the creator of this subscription. + CreatedByAccount *Account `bun:"-"` // Account corresponding to createdByAccountID. + URI string `bun:",nullzero,notnull,unique"` // URI of the domain permission list. + ContentType DomainPermSubContentType `bun:",nullzero,notnull"` // Content type to expect from the URI. + FetchUsername string `bun:",nullzero"` // Username to send when doing a GET of URI using basic auth. + FetchPassword string `bun:",nullzero"` // Password to send when doing a GET of URI using basic auth. + FetchedAt time.Time `bun:"type:timestamptz,nullzero"` // Time when fetch of URI was last attempted. + SuccessfullyFetchedAt time.Time `bun:"type:timestamptz,nullzero"` // Time when the domain permission list was last *successfuly* fetched, to be transmitted as If-Modified-Since header. + ETag string `bun:",nullzero"` // Etag last received from the server (if any) on successful fetch. + Error string `bun:",nullzero"` // If latest fetch attempt errored, this field stores the error message. Cleared on latest successful fetch. + Count uint64 `bun:""` // Count of domain permission entries discovered at URI. +} + +type DomainPermSubContentType enumType + +const ( + DomainPermSubContentTypeUnknown DomainPermSubContentType = 0 // ??? + DomainPermSubContentTypeCSV DomainPermSubContentType = 1 // text/csv + DomainPermSubContentTypeJSON DomainPermSubContentType = 2 // application/json + DomainPermSubContentTypePlain DomainPermSubContentType = 3 // text/plain +) + +func (p DomainPermSubContentType) String() string { + switch p { + case DomainPermSubContentTypeCSV: + return "text/csv" + case DomainPermSubContentTypeJSON: + return "application/json" + case DomainPermSubContentTypePlain: + return "text/plain" + default: + panic("unknown content type") + } +} + +func NewDomainPermSubContentType(in string) DomainPermSubContentType { + switch in { + case "text/csv": + return DomainPermSubContentTypeCSV + case "application/json": + return DomainPermSubContentTypeJSON + case "text/plain": + return DomainPermSubContentTypePlain + default: + return DomainPermSubContentTypeUnknown + } +} diff --git a/internal/id/ulid.go b/internal/id/ulid.go index 8c0b1e94c..3c57c9f1b 100644 --- a/internal/id/ulid.go +++ b/internal/id/ulid.go @@ -83,3 +83,12 @@ func NewRandomULID() (string, error) { } return newUlid.String(), nil } + +func TimeFromULID(id string) (time.Time, error) { + parsed, err := ulid.ParseStrict(id) + if err != nil { + return time.Time{}, err + } + + return ulid.Time(parsed.Time()), nil +} diff --git a/internal/processing/admin/domainpermissionsubscription.go b/internal/processing/admin/domainpermissionsubscription.go new file mode 100644 index 000000000..3d2f63d56 --- /dev/null +++ b/internal/processing/admin/domainpermissionsubscription.go @@ -0,0 +1,285 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package admin + +import ( + "context" + "errors" + "fmt" + "net/url" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/paging" +) + +// DomainPermissionSubscriptionGet returns one +// domain permission subscription with the given id. +func (p *Processor) DomainPermissionSubscriptionGet( + ctx context.Context, + id string, +) (*apimodel.DomainPermissionSubscription, gtserror.WithCode) { + permSub, err := p.state.DB.GetDomainPermissionSubscriptionByID(ctx, id) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting domain permission subscription %s: %w", id, err) + return nil, gtserror.NewErrorInternalError(err) + } + + if permSub == nil { + err := fmt.Errorf("domain permission subscription %s not found", id) + return nil, gtserror.NewErrorNotFound(err, err.Error()) + } + + return p.apiDomainPermSub(ctx, permSub) +} + +// DomainPermissionSubscriptionsGet returns a page of +// DomainPermissionSubscriptions with the given parameters. +func (p *Processor) DomainPermissionSubscriptionsGet( + ctx context.Context, + permType gtsmodel.DomainPermissionType, + page *paging.Page, +) (*apimodel.PageableResponse, gtserror.WithCode) { + permSubs, err := p.state.DB.GetDomainPermissionSubscriptions( + ctx, + permType, + page, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + count := len(permSubs) + if count == 0 { + return paging.EmptyResponse(), nil + } + + // Get the lowest and highest + // ID values, used for paging. + lo := permSubs[count-1].ID + hi := permSubs[0].ID + + // Convert each perm sub to API model. + items := make([]any, len(permSubs)) + for i, permSub := range permSubs { + apiPermSub, err := p.converter.DomainPermSubToAPIDomainPermSub(ctx, permSub) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + items[i] = apiPermSub + } + + // Assemble next/prev page queries. + query := make(url.Values, 1) + if permType != gtsmodel.DomainPermissionUnknown { + query.Set(apiutil.DomainPermissionPermTypeKey, permType.String()) + } + + return paging.PackageResponse(paging.ResponseParams{ + Items: items, + Path: "/api/v1/admin/domain_permission_subscriptions", + Next: page.Next(lo, hi), + Prev: page.Prev(lo, hi), + Query: query, + }), nil +} + +// DomainPermissionSubscriptionsGetByPriority returns all domain permission +// subscriptions of the given permission type, in descending priority order. +func (p *Processor) DomainPermissionSubscriptionsGetByPriority( + ctx context.Context, + permType gtsmodel.DomainPermissionType, +) ([]*apimodel.DomainPermissionSubscription, gtserror.WithCode) { + permSubs, err := p.state.DB.GetDomainPermissionSubscriptionsByPriority( + ctx, + permType, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Convert each perm sub to API model. + items := make([]*apimodel.DomainPermissionSubscription, len(permSubs)) + for i, permSub := range permSubs { + apiPermSub, err := p.converter.DomainPermSubToAPIDomainPermSub(ctx, permSub) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + items[i] = apiPermSub + } + + return items, nil +} + +func (p *Processor) DomainPermissionSubscriptionCreate( + ctx context.Context, + acct *gtsmodel.Account, + priority uint8, + title string, + uri string, + contentType gtsmodel.DomainPermSubContentType, + permType gtsmodel.DomainPermissionType, + asDraft bool, + fetchUsername string, + fetchPassword string, +) (*apimodel.DomainPermissionSubscription, gtserror.WithCode) { + permSub := >smodel.DomainPermissionSubscription{ + ID: id.NewULID(), + Priority: priority, + Title: title, + PermissionType: permType, + AsDraft: &asDraft, + CreatedByAccountID: acct.ID, + CreatedByAccount: acct, + URI: uri, + ContentType: contentType, + FetchUsername: fetchUsername, + FetchPassword: fetchPassword, + } + + err := p.state.DB.PutDomainPermissionSubscription(ctx, permSub) + if err != nil { + if errors.Is(err, db.ErrAlreadyExists) { + // Unique constraint conflict. + const errText = "domain permission subscription with given URI or title already exists" + return nil, gtserror.NewErrorConflict(errors.New(errText), errText) + } + + // Real database error. + err := gtserror.Newf("db error putting domain permission subscription: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return p.apiDomainPermSub(ctx, permSub) +} + +func (p *Processor) DomainPermissionSubscriptionUpdate( + ctx context.Context, + id string, + priority *uint8, + title *string, + uri *string, + contentType *gtsmodel.DomainPermSubContentType, + asDraft *bool, + adoptOrphans *bool, + fetchUsername *string, + fetchPassword *string, +) (*apimodel.DomainPermissionSubscription, gtserror.WithCode) { + permSub, err := p.state.DB.GetDomainPermissionSubscriptionByID(ctx, id) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting domain permission subscription %s: %w", id, err) + return nil, gtserror.NewErrorInternalError(err) + } + + if permSub == nil { + err := fmt.Errorf("domain permission subscription %s not found", id) + return nil, gtserror.NewErrorNotFound(err, err.Error()) + } + + columns := make([]string, 0, 7) + + if priority != nil { + permSub.Priority = *priority + columns = append(columns, "priority") + } + + if title != nil { + permSub.Title = *title + columns = append(columns, "title") + } + + if uri != nil { + permSub.URI = *uri + columns = append(columns, "uri") + } + + if contentType != nil { + permSub.ContentType = *contentType + columns = append(columns, "content_type") + } + + if asDraft != nil { + permSub.AsDraft = asDraft + columns = append(columns, "as_draft") + } + + if adoptOrphans != nil { + permSub.AdoptOrphans = adoptOrphans + columns = append(columns, "adopt_orphans") + } + + if fetchPassword != nil { + permSub.FetchPassword = *fetchPassword + columns = append(columns, "fetch_password") + } + + if fetchUsername != nil { + permSub.FetchUsername = *fetchUsername + columns = append(columns, "fetch_username") + } + + err = p.state.DB.UpdateDomainPermissionSubscription(ctx, permSub, columns...) + if err != nil { + if errors.Is(err, db.ErrAlreadyExists) { + // Unique constraint conflict. + const errText = "domain permission subscription with given URI or title already exists" + return nil, gtserror.NewErrorConflict(errors.New(errText), errText) + } + + // Real database error. + err := gtserror.Newf("db error updating domain permission subscription: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return p.apiDomainPermSub(ctx, permSub) +} + +func (p *Processor) DomainPermissionSubscriptionRemove( + ctx context.Context, + id string, + removeChildren bool, +) (*apimodel.DomainPermissionSubscription, gtserror.WithCode) { + permSub, err := p.state.DB.GetDomainPermissionSubscriptionByID(ctx, id) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting domain permission subscription %s: %w", id, err) + return nil, gtserror.NewErrorInternalError(err) + } + + if permSub == nil { + err := fmt.Errorf("domain permission subscription %s not found", id) + return nil, gtserror.NewErrorNotFound(err, err.Error()) + } + + // TODO in next PR: if removeChildren, then remove all + // domain permissions that are children of this domain + // permission subscription. If not removeChildren, then + // just unlink them by clearing their subscription ID. + // For now just delete the domain permission subscription. + if err := p.state.DB.DeleteDomainPermissionSubscription(ctx, id); err != nil { + err := gtserror.Newf("db error deleting domain permission subscription: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return p.apiDomainPermSub(ctx, permSub) +} diff --git a/internal/processing/admin/util.go b/internal/processing/admin/util.go index bc59a2b3b..aef435856 100644 --- a/internal/processing/admin/util.go +++ b/internal/processing/admin/util.go @@ -115,3 +115,19 @@ func (p *Processor) apiDomainPerm( return apiDomainPerm, nil } + +// apiDomainPermSub is a cheeky shortcut for returning the +// API version of the given domain permission subscription, +// or an appropriate error if something goes wrong. +func (p *Processor) apiDomainPermSub( + ctx context.Context, + domainPermSub *gtsmodel.DomainPermissionSubscription, +) (*apimodel.DomainPermissionSubscription, gtserror.WithCode) { + apiDomainPermSub, err := p.converter.DomainPermSubToAPIDomainPermSub(ctx, domainPermSub) + if err != nil { + err := gtserror.NewfAt(3, "error converting domain permission subscription to api model: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return apiDomainPermSub, nil +} diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index e0276a53b..40671d884 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -36,6 +36,7 @@ "github.com/superseriousbusiness/gotosocial/internal/filter/usermute" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/language" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/media" @@ -2002,6 +2003,54 @@ func (c *Converter) DomainPermToAPIDomainPerm( return domainPerm, nil } +func (c *Converter) DomainPermSubToAPIDomainPermSub( + ctx context.Context, + d *gtsmodel.DomainPermissionSubscription, +) (*apimodel.DomainPermissionSubscription, error) { + createdAt, err := id.TimeFromULID(d.ID) + if err != nil { + return nil, gtserror.Newf("error converting id to time: %w", err) + } + + // URI may be in Punycode, + // de-punify it just in case. + uri, err := util.DePunify(d.URI) + if err != nil { + return nil, gtserror.Newf("error de-punifying URI %s: %w", d.URI, err) + } + + var ( + fetchedAt string + successfullyFetchedAt string + ) + + if !d.FetchedAt.IsZero() { + fetchedAt = util.FormatISO8601(d.FetchedAt) + } + + if !d.SuccessfullyFetchedAt.IsZero() { + successfullyFetchedAt = util.FormatISO8601(d.SuccessfullyFetchedAt) + } + + return &apimodel.DomainPermissionSubscription{ + ID: d.ID, + Priority: d.Priority, + Title: d.Title, + PermissionType: d.PermissionType.String(), + AsDraft: *d.AsDraft, + AdoptOrphans: *d.AdoptOrphans, + CreatedBy: d.CreatedByAccountID, + CreatedAt: util.FormatISO8601(createdAt), + URI: uri, + ContentType: d.ContentType.String(), + FetchUsername: d.FetchUsername, + FetchPassword: d.FetchPassword, + FetchedAt: fetchedAt, + SuccessfullyFetchedAt: successfullyFetchedAt, + Error: d.Error, + }, nil +} + // ReportToAPIReport converts a gts model report into an api model report, for serving at /api/v1/reports func (c *Converter) ReportToAPIReport(ctx context.Context, r *gtsmodel.Report) (*apimodel.Report, error) { report := &apimodel.Report{ diff --git a/web/source/settings/components/form/inputs.tsx b/web/source/settings/components/form/inputs.tsx index 06075ea87..498499db6 100644 --- a/web/source/settings/components/form/inputs.tsx +++ b/web/source/settings/components/form/inputs.tsx @@ -26,6 +26,7 @@ import type { import type { FileFormInputHook, + NumberFormInputHook, RadioFormInputHook, TextFormInputHook, } from "../../lib/form/types"; @@ -57,6 +58,32 @@ export function TextInput({label, field, ...props}: TextInputProps) { ); } +export interface NumberInputProps extends React.DetailedHTMLProps< + React.InputHTMLAttributes, + HTMLInputElement +> { + label?: ReactNode; + field: NumberFormInputHook; +} + +export function NumberInput({label, field, ...props}: NumberInputProps) { + const { onChange, value, ref } = field; + + return ( +
+ +
+ ); +} + export interface TextAreaProps extends React.DetailedHTMLProps< React.TextareaHTMLAttributes, HTMLTextAreaElement diff --git a/web/source/settings/lib/form/index.ts b/web/source/settings/lib/form/index.ts index 409ef0328..878b7c79b 100644 --- a/web/source/settings/lib/form/index.ts +++ b/web/source/settings/lib/form/index.ts @@ -41,6 +41,7 @@ import type { ChecklistInputHook, FieldArrayInputHook, ArrayInputHook, + NumberFormInputHook, } from "./types"; function capitalizeFirst(str: string) { @@ -102,11 +103,11 @@ function value(name: string, initialValue: T) { name, Name: "", value: initialValue, - hasChanged: () => true, // always included }; } export const useTextInput = inputHook(text) as (_name: string, _opts?: HookOpts) => TextFormInputHook; +export const useNumberInput = inputHook(text) as (_name: string, _opts?: HookOpts) => NumberFormInputHook; export const useFileInput = inputHook(file) as (_name: string, _opts?: HookOpts) => FileFormInputHook; export const useBoolInput = inputHook(bool) as (_name: string, _opts?: HookOpts) => BoolFormInputHook; export const useRadioInput = inputHook(radio) as (_name: string, _opts?: HookOpts) => RadioFormInputHook; diff --git a/web/source/settings/lib/form/number.tsx b/web/source/settings/lib/form/number.tsx new file mode 100644 index 000000000..15acf162a --- /dev/null +++ b/web/source/settings/lib/form/number.tsx @@ -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 . +*/ + +import React, { + useState, + useRef, + useTransition, + useEffect, +} from "react"; + +import type { + CreateHookNames, + HookOpts, + NumberFormInputHook, +} from "./types"; + +const _default = 0; + +export default function useNumberInput( + { name, Name }: CreateHookNames, + { + initialValue = _default, + dontReset = false, + validator, + showValidation = true, + initValidation, + nosubmit = false, + }: HookOpts +): NumberFormInputHook { + const [number, setNumber] = useState(initialValue); + const numberRef = useRef(null); + + const [validation, setValidation] = useState(initValidation ?? ""); + const [_isValidating, startValidation] = useTransition(); + const valid = validation == ""; + + function onChange(e: React.ChangeEvent) { + const input = e.target.valueAsNumber; + setNumber(input); + + if (validator) { + startValidation(() => { + setValidation(validator(input)); + }); + } + } + + function reset() { + if (!dontReset) { + setNumber(initialValue); + } + } + + useEffect(() => { + if (validator && numberRef.current) { + if (showValidation) { + numberRef.current.setCustomValidity(validation); + } else { + numberRef.current.setCustomValidity(""); + } + } + }, [validation, validator, showValidation]); + + // Array / Object hybrid, for easier access in different contexts + return Object.assign([ + onChange, + reset, + { + [name]: number, + [`${name}Ref`]: numberRef, + [`set${Name}`]: setNumber, + [`${name}Valid`]: valid, + } + ], { + onChange, + reset, + name, + Name: "", // Will be set by inputHook function. + nosubmit, + value: number, + ref: numberRef, + setter: setNumber, + valid, + validate: () => setValidation(validator ? validator(number): ""), + hasChanged: () => number != initialValue, + _default + }); +} diff --git a/web/source/settings/lib/form/submit.ts b/web/source/settings/lib/form/submit.ts index aa662086f..498481deb 100644 --- a/web/source/settings/lib/form/submit.ts +++ b/web/source/settings/lib/form/submit.ts @@ -34,8 +34,24 @@ import type { } from "./types"; interface UseFormSubmitOptions { + /** + * Include only changed fields when submitting the form. + * If no fields have been changed, submit will be a noop. + */ changedOnly: boolean; + /** + * Optional function to run when the form has been sent + * and a response has been returned from the server. + */ onFinish?: ((_res: any) => void); + /** + * Can be optionally used to modify the final mutation argument from the + * gathered mutation data before it's passed into the trigger function. + * + * Useful if the mutation trigger function takes not just a simple key/value + * object but a more complicated object. + */ + customizeMutationArgs?: (_mutationData: { [k: string]: any }) => any; } /** @@ -105,7 +121,7 @@ export default function useFormSubmit( usedAction.current = action; // Transform the hooked form into an object. - const { + let { mutationData, updatedFields, } = getFormMutations(form, { changedOnly }); @@ -117,7 +133,12 @@ export default function useFormSubmit( return; } + // Final tweaks on the mutation + // argument before triggering it. mutationData.action = action; + if (opts.customizeMutationArgs) { + mutationData = opts.customizeMutationArgs(mutationData); + } try { const res = await runMutation(mutationData); diff --git a/web/source/settings/lib/form/types.ts b/web/source/settings/lib/form/types.ts index 17fbec53a..6a4162f3a 100644 --- a/web/source/settings/lib/form/types.ts +++ b/web/source/settings/lib/form/types.ts @@ -181,6 +181,13 @@ export interface TextFormInputHook extends FormInputHook, _withValidate, _withRef {} +export interface NumberFormInputHook extends FormInputHook, + _withSetter, + _withOnChange, + _withReset, + _withValidate, + _withRef {} + export interface RadioFormInputHook extends FormInputHook, _withSetter, _withOnChange, diff --git a/web/source/settings/lib/query/admin/domain-permissions/subscriptions.ts b/web/source/settings/lib/query/admin/domain-permissions/subscriptions.ts new file mode 100644 index 000000000..f065aaf54 --- /dev/null +++ b/web/source/settings/lib/query/admin/domain-permissions/subscriptions.ts @@ -0,0 +1,164 @@ +/* + 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 { + DomainPermSub, + DomainPermSubCreateUpdateParams, + DomainPermSubSearchParams, + DomainPermSubSearchResp, +} from "../../../types/domain-permission"; +import parse from "parse-link-header"; +import { PermType } from "../../../types/perm"; + +const extended = gtsApi.injectEndpoints({ + endpoints: (build) => ({ + searchDomainPermissionSubscriptions: 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_subscriptions${query}` + }; + }, + // Headers required for paging. + transformResponse: (apiResp: DomainPermSub[], meta) => { + const subs = apiResp; + const linksStr = meta?.response?.headers.get("Link"); + const links = parse(linksStr); + return { subs, links }; + }, + // Only provide TRANSFORMED tag id since this model is not the same + // as getDomainPermissionSubscription model (due to transformResponse). + providesTags: [{ type: "DomainPermissionSubscription", id: "TRANSFORMED" }] + }), + + getDomainPermissionSubscriptionsPreview: build.query({ + query: (permType) => ({ + url: `/api/v1/admin/domain_permission_subscriptions/preview?permission_type=${permType}` + }), + providesTags: (_result, _error, permType) => + // Cache by permission type. + [{ type: "DomainPermissionSubscription", id: `${permType}sByPriority` }] + }), + + getDomainPermissionSubscription: build.query({ + query: (id) => ({ + url: `/api/v1/admin/domain_permission_subscriptions/${id}` + }), + providesTags: (_result, _error, id) => [ + { type: 'DomainPermissionSubscription', id } + ], + }), + + createDomainPermissionSubscription: build.mutation({ + query: (formData) => ({ + method: "POST", + url: `/api/v1/admin/domain_permission_subscriptions`, + asForm: true, + body: formData, + discardEmpty: true + }), + invalidatesTags: (_res, _error, formData) => + [ + // Invalidate transformed list of all perm subs. + { type: "DomainPermissionSubscription", id: "TRANSFORMED" }, + // Invalidate perm subs of this type sorted by priority. + { type: "DomainPermissionSubscription", id: `${formData.permission_type}sByPriority` } + ] + }), + + updateDomainPermissionSubscription: build.mutation({ + query: ({ id, formData }) => ({ + method: "PATCH", + url: `/api/v1/admin/domain_permission_subscriptions/${id}`, + asForm: true, + body: formData, + }), + invalidatesTags: (_res, _error, { id, permType }) => + [ + // Invalidate this perm sub. + { type: "DomainPermissionSubscription", id: id }, + // Invalidate transformed list of all perms subs. + { type: "DomainPermissionSubscription", id: "TRANSFORMED" }, + // Invalidate perm subs of this type sorted by priority. + { type: "DomainPermissionSubscription", id: `${permType}sByPriority` } + ], + }), + + removeDomainPermissionSubscription: build.mutation({ + query: ({ id, remove_children }) => ({ + method: "POST", + url: `/api/v1/admin/domain_permission_subscriptions/${id}/remove`, + asForm: true, + body: { remove_children: remove_children }, + }), + }) + }), +}); + +/** + * View domain permission subscriptions. + */ +const useLazySearchDomainPermissionSubscriptionsQuery = extended.useLazySearchDomainPermissionSubscriptionsQuery; + +/** + * Get domain permission subscription with the given ID. + */ +const useGetDomainPermissionSubscriptionQuery = extended.useGetDomainPermissionSubscriptionQuery; + +/** + * Create a domain permission subscription with the given parameters. + */ +const useCreateDomainPermissionSubscriptionMutation = extended.useCreateDomainPermissionSubscriptionMutation; + +/** + * View domain permission subscriptions of selected perm type, sorted by priority descending. + */ +const useGetDomainPermissionSubscriptionsPreviewQuery = extended.useGetDomainPermissionSubscriptionsPreviewQuery; + +/** + * Update domain permission subscription. + */ +const useUpdateDomainPermissionSubscriptionMutation = extended.useUpdateDomainPermissionSubscriptionMutation; + +/** + * Remove a domain permission subscription and optionally its children (harsh). + */ +const useRemoveDomainPermissionSubscriptionMutation = extended.useRemoveDomainPermissionSubscriptionMutation; + +export { + useLazySearchDomainPermissionSubscriptionsQuery, + useGetDomainPermissionSubscriptionQuery, + useCreateDomainPermissionSubscriptionMutation, + useGetDomainPermissionSubscriptionsPreviewQuery, + useUpdateDomainPermissionSubscriptionMutation, + useRemoveDomainPermissionSubscriptionMutation, +}; diff --git a/web/source/settings/lib/query/gts-api.ts b/web/source/settings/lib/query/gts-api.ts index 9543819a9..34b66913a 100644 --- a/web/source/settings/lib/query/gts-api.ts +++ b/web/source/settings/lib/query/gts-api.ts @@ -170,7 +170,8 @@ export const gtsApi = createApi({ "DefaultInteractionPolicies", "InteractionRequest", "DomainPermissionDraft", - "DomainPermissionExclude" + "DomainPermissionExclude", + "DomainPermissionSubscription" ], 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 1a0a9bd0b..c4560d79b 100644 --- a/web/source/settings/lib/types/domain-permission.ts +++ b/web/source/settings/lib/types/domain-permission.ts @@ -20,6 +20,7 @@ import typia from "typia"; import { PermType } from "./perm"; import { Links } from "parse-link-header"; +import { PermSubContentType } from "./permsubcontenttype"; export const validateDomainPerms = typia.createValidate(); @@ -213,3 +214,156 @@ export interface DomainPermExcludeCreateParams { */ private_comment?: string; } + +/** + * API model of one domain permission susbcription. + */ +export interface DomainPermSub { + /** + * The ID of the domain permission subscription. + */ + id: string; + /** + * The priority of the domain permission subscription. + */ + priority: number; + /** + * Time at which the subscription was created (ISO 8601 Datetime). + */ + created_at: string; + /** + * Title of this subscription, as set by admin who created or updated it. + */ + title: string; + /** + * The type of domain permission subscription (allow, block). + */ + permission_type: PermType; + /** + * 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. + */ + as_draft: boolean; + /** + * If true, this domain permission subscription will "adopt" domain permissions + * which already exist on the instance, and which meet the following conditions: + * 1) they have no subscription ID (ie., they're "orphaned") and 2) they are present + * in the subscribed list. Such orphaned domain permissions will be given this + * subscription's subscription ID value and be managed by this subscription. + */ + adopt_orphans: boolean; + /** + * ID of the account that created this subscription. + */ + created_by: string; + /** + * URI to call in order to fetch the permissions list. + */ + uri: string; + /** + * MIME content type to use when parsing the permissions list. + */ + content_type: PermSubContentType; + /** + * (Optional) username to set for basic auth when doing a fetch of URI. + */ + fetch_username?: string; + /** + * (Optional) password to set for basic auth when doing a fetch of URI. + */ + fetch_password?: string; + /** + * Time at which the most recent fetch was attempted (ISO 8601 Datetime). + */ + fetched_at?: string; + /** + * Time of the most recent successful fetch (ISO 8601 Datetime). + */ + successfully_fetched_at?: string; + /** + * If most recent fetch attempt failed, this field will contain an error message related to the fetch attempt. + */ + error?: string; + /** + * Count of domain permission entries discovered at URI on last (successful) fetch. + */ + count: number; +} + +/** + * Parameters for GET to /api/v1/admin/domain_permission_subscriptions. + */ +export interface DomainPermSubSearchParams { + /** + * Return only block or allow subscriptions. + */ + 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; +} + +export interface DomainPermSubCreateUpdateParams { + /** + * The priority of the domain permission subscription. + */ + priority?: number; + /** + * Title of this subscription, as set by admin who created or updated it. + */ + title?: string; + /** + * URI to call in order to fetch the permissions list. + */ + uri: string; + /** + * MIME content type to use when parsing the permissions list. + */ + content_type: PermSubContentType; + /** + * 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. + */ + as_draft?: boolean; + /** + * If true, this domain permission subscription will "adopt" domain permissions + * which already exist on the instance, and which meet the following conditions: + * 1) they have no subscription ID (ie., they're "orphaned") and 2) they are present + * in the subscribed list. Such orphaned domain permissions will be given this + * subscription's subscription ID value and be managed by this subscription. + */ + adopt_orphans?: boolean; + /** + * (Optional) username to set for basic auth when doing a fetch of URI. + */ + fetch_username?: string; + /** + * (Optional) password to set for basic auth when doing a fetch of URI. + */ + fetch_password?: string; + /** + * The type of domain permission subscription to create or update (allow, block). + */ + permission_type: PermType; +} + +export interface DomainPermSubSearchResp { + subs: DomainPermSub[]; + links: Links | null; +} diff --git a/web/source/settings/lib/types/permsubcontenttype.ts b/web/source/settings/lib/types/permsubcontenttype.ts new file mode 100644 index 000000000..0468aae4d --- /dev/null +++ b/web/source/settings/lib/types/permsubcontenttype.ts @@ -0,0 +1,20 @@ +/* + 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 . +*/ + +export type PermSubContentType = "text/plain" | "text/csv" | "application/json"; diff --git a/web/source/settings/lib/util/formvalidators.ts b/web/source/settings/lib/util/formvalidators.ts index c509cf59d..358db616c 100644 --- a/web/source/settings/lib/util/formvalidators.ts +++ b/web/source/settings/lib/util/formvalidators.ts @@ -46,3 +46,22 @@ export function formDomainValidator(domain: string): string { return "invalid domain"; } + +export function urlValidator(urlStr: string): string { + if (urlStr.length === 0) { + return ""; + } + + let url: URL; + try { + url = new URL(urlStr); + } catch (e) { + return e.message; + } + + if (url.protocol !== "http:" && url.protocol !== "https:") { + return `invalid protocol, must be http or https`; + } + + return formDomainValidator(url.host); +} diff --git a/web/source/settings/style.css b/web/source/settings/style.css index 740c30059..bbb8fd61c 100644 --- a/web/source/settings/style.css +++ b/web/source/settings/style.css @@ -1360,9 +1360,12 @@ button.tab-button { } .domain-permission-drafts-view, -.domain-permission-excludes-view { +.domain-permission-excludes-view, +.domain-permission-subscriptions-view, +.domain-permission-subscriptions-preview { .domain-permission-draft, - .domain-permission-exclude { + .domain-permission-exclude, + .domain-permission-subscription { display: flex; flex-direction: column; flex-wrap: nowrap; @@ -1404,14 +1407,18 @@ button.tab-button { } .domain-permission-draft-details, -.domain-permission-exclude-details { +.domain-permission-exclude-details, +.domain-permission-subscription-details { .info-list { margin-top: 1rem; } } .domain-permission-drafts-view, -.domain-permission-draft-details { +.domain-permission-draft-details, +.domain-permission-subscriptions-view, +.domain-permission-subscription-details, +.domain-permission-subscriptions-preview { dd.permission-type { display: flex; gap: 0.35rem; @@ -1419,6 +1426,35 @@ button.tab-button { } } +.domain-permission-subscription-title { + font-size: 1.2rem; + font-weight: bold; +} + +.domain-permission-subscription-create, +.domain-permission-subscription-update { + gap: 1rem; + + .password-show-hide { + display: flex; + gap: 0.5rem; + + .form-field.text { + flex: 1; + } + + .password-show-hide-toggle { + font-size: 1rem; + line-height: 1.4rem; + align-self: flex-end; + } + } +} + +.domain-permission-subscription-remove { + gap: 1rem; +} + .instance-rules { list-style-position: inside; margin: 0; diff --git a/web/source/settings/views/moderation/domain-permissions/subscriptions/common.tsx b/web/source/settings/views/moderation/domain-permissions/subscriptions/common.tsx new file mode 100644 index 000000000..8668caa4b --- /dev/null +++ b/web/source/settings/views/moderation/domain-permissions/subscriptions/common.tsx @@ -0,0 +1,181 @@ +/* + 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, { useMemo } from "react"; +import { useLocation } from "wouter"; +import { DomainPermSub } from "../../../../lib/types/domain-permission"; +import { yesOrNo } from "../../../../lib/util"; + +export function DomainPermissionSubscriptionHelpText() { + return ( + <> + Domain permission subscriptions allow your instance to "subscribe" to a list of block or allows at a given url. +
+ Every 24 hours, each subscribed list is fetched by your instance, and any discovered + permissions in each list are loaded into your instance as blocks/allows/drafts. + + ); +} + +export function DomainPermissionSubscriptionDocsLink() { + return ( + + Learn more about domain permission subscriptions (opens in a new tab) + + ); +} + +export interface SubscriptionEntryProps { + permSub: DomainPermSub; + linkTo: string; + backLocation: string; +} + +export function SubscriptionListEntry({ permSub, linkTo, backLocation }: SubscriptionEntryProps) { + const [ _location, setLocation ] = useLocation(); + + const permType = permSub.permission_type; + if (!permType) { + throw "permission_type was undefined"; + } + + const { + priority, + title, + uri, + as_draft: asDraft, + adopt_orphans: adoptOrphans, + content_type: contentType, + fetched_at: fetchedAt, + successfully_fetched_at: successfullyFetchedAt, + count, + } = permSub; + + const ariaLabel = useMemo(() => { + let ariaLabel = ""; + + // Prepend title. + if (title.length !== 0) { + ariaLabel += `${title}, create `; + } else { + ariaLabel += "Create "; + } + + // Add perm type. + ariaLabel += permType; + + // Alter wording + // if using drafts. + if (asDraft) { + ariaLabel += " drafts from "; + } else { + ariaLabel += "s from "; + } + + // Add url. + ariaLabel += uri; + + return ariaLabel; + }, [title, permType, asDraft, uri]); + + let fetchedAtStr = "never"; + if (fetchedAt) { + fetchedAtStr = new Date(fetchedAt).toDateString(); + } + + let successfullyFetchedAtStr = "never"; + if (successfullyFetchedAt) { + successfullyFetchedAtStr = new Date(successfullyFetchedAt).toDateString(); + } + + return ( + { + // When clicking on a subscription, direct + // to the detail view for that subscription. + 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} + > +
+ { permSub.title !== "" && + + {title} + + } +
+
Priority:
+
{priority}
+
+
+
Permission type:
+
+ + {permType} +
+
+
+
URL:
+
{uri}
+
+
+
Content type:
+
{contentType}
+
+
+
Create as draft:
+
{yesOrNo(asDraft)}
+
+
+
Adopt orphans:
+
{yesOrNo(adoptOrphans)}
+
+
+
Last fetch attempt:
+
{fetchedAtStr}
+
+
+
Last successful fetch:
+
{successfullyFetchedAtStr}
+
+
+
Discovered {permType}s:
+
{count}
+
+
+
+ ); +} diff --git a/web/source/settings/views/moderation/domain-permissions/subscriptions/detail.tsx b/web/source/settings/views/moderation/domain-permissions/subscriptions/detail.tsx new file mode 100644 index 000000000..408d81b92 --- /dev/null +++ b/web/source/settings/views/moderation/domain-permissions/subscriptions/detail.tsx @@ -0,0 +1,384 @@ +/* + 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, { useState } from "react"; +import { useLocation, useParams } from "wouter"; +import { useBaseUrl } from "../../../../lib/navigation/util"; +import BackButton from "../../../../components/back-button"; +import { useGetDomainPermissionSubscriptionQuery, useRemoveDomainPermissionSubscriptionMutation, useUpdateDomainPermissionSubscriptionMutation } from "../../../../lib/query/admin/domain-permissions/subscriptions"; +import { useBoolInput, useNumberInput, useTextInput } from "../../../../lib/form"; +import FormWithData from "../../../../lib/form/form-with-data"; +import { DomainPermSub } from "../../../../lib/types/domain-permission"; +import MutationButton from "../../../../components/form/mutation-button"; +import { Checkbox, NumberInput, Select, TextInput } from "../../../../components/form/inputs"; +import useFormSubmit from "../../../../lib/form/submit"; +import UsernameLozenge from "../../../../components/username-lozenge"; +import { urlValidator } from "../../../../lib/util/formvalidators"; + +export default function DomainPermissionSubscriptionDetail() { + const params = useParams(); + let id = params.permSubId as string | undefined; + if (!id) { + throw "no permSub ID"; + } + + return ( + + ); +} + +function DomainPermSubForm({ data: permSub }: { data: DomainPermSub }) { + const baseUrl = useBaseUrl(); + const backLocation: string = history.state?.backLocation ?? `~${baseUrl}/subscriptions/search`; + + return ( +
+

Domain Permission Subscription Detail

+ + + +
+ ); +} + +function DomainPermSubDetails({ permSub }: { permSub: DomainPermSub }) { + const [ location ] = useLocation(); + const baseUrl = useBaseUrl(); + + const permType = permSub.permission_type; + if (!permType) { + throw "permission_type was undefined"; + } + + const created = new Date(permSub.created_at).toDateString(); + let fetchedAtStr = "never"; + if (permSub.fetched_at) { + fetchedAtStr = new Date(permSub.fetched_at).toDateString(); + } + + let successfullyFetchedAtStr = "never"; + if (permSub.successfully_fetched_at) { + successfullyFetchedAtStr = new Date(permSub.successfully_fetched_at).toDateString(); + } + + return ( +
+
+
Permission type:
+
+ + {permType} +
+
+
+
ID
+
{permSub.id}
+
+
+
Created
+
+
+
+
Created By
+
+ +
+
+
+
Last fetch attempt:
+
{fetchedAtStr}
+
+
+
Last successful fetch:
+
{successfullyFetchedAtStr}
+
+
+
Discovered {permSub.permission_type}s:
+
{permSub.count}
+
+
+ ); +} + +function UpdateDomainPermSub({ permSub }: { permSub: DomainPermSub }) { + const [ showPassword, setShowPassword ] = useState(false); + const form = { + priority: useNumberInput("priority", { source: permSub }), + uri: useTextInput("uri", { + source: permSub, + validator: urlValidator, + }), + content_type: useTextInput("content_type", { source: permSub }), + title: useTextInput("title", { source: permSub }), + as_draft: useBoolInput("as_draft", { source: permSub }), + adopt_orphans: useBoolInput("adopt_orphans", { source: permSub }), + useBasicAuth: useBoolInput("useBasicAuth", { + defaultValue: + (permSub.fetch_password !== undefined && permSub.fetch_password !== "") || + (permSub.fetch_username !== undefined && permSub.fetch_username !== ""), + nosubmit: true + }), + fetch_username: useTextInput("fetch_username", { + source: permSub + }), + fetch_password: useTextInput("fetch_password", { + source: permSub + }), + }; + + const [submitUpdate, updateResult] = useFormSubmit( + form, + useUpdateDomainPermissionSubscriptionMutation(), + { + changedOnly: true, + customizeMutationArgs: (mutationData) => { + // Clear username + password if they were set, + // but user has selected to not use basic auth. + if (!form.useBasicAuth.value) { + if (permSub.fetch_username !== undefined && permSub.fetch_username !== "") { + mutationData["fetch_username"] = ""; + } + if (permSub.fetch_password !== undefined && permSub.fetch_password !== "") { + mutationData["fetch_password"] = ""; + } + } + + // Remove useBasicAuth if included. + delete mutationData["useBasicAuth"]; + + // Modify mutation argument to + // include ID and permission type. + return { + id: permSub.id, + permType: permSub.permission_type, + formData: mutationData, + }; + }, + onFinish: res => { + // On a successful response that returns data, + // clear the fetch_username and fetch_password + // fields if they weren't set on the returned sub. + if (res.data) { + if (res.data.fetch_username === undefined || res.data.fetch_username === "") { + form.fetch_username.setter(""); + } + if (res.data.fetch_password === undefined || res.data.fetch_password === "") { + form.fetch_password.setter(""); + } + } + } + } + ); + + const submitDisabled = () => { + // If no basic auth, we don't care what + // fetch_password and fetch_username are. + if (!form.useBasicAuth.value) { + return false; + } + + // Either of fetch_password or fetch_username must be set. + return !(form.fetch_password.value || form.fetch_username.value); + }; + + return ( +
+

Edit Subscription

+ + + + + + + + + + + + } + > + + + + No subscriptions found that match your query.} + prevNextLinks={searchRes.data?.links} + /> + + ); +} diff --git a/web/source/settings/views/moderation/domain-permissions/subscriptions/new.tsx b/web/source/settings/views/moderation/domain-permissions/subscriptions/new.tsx new file mode 100644 index 000000000..e29e3d755 --- /dev/null +++ b/web/source/settings/views/moderation/domain-permissions/subscriptions/new.tsx @@ -0,0 +1,230 @@ +/* + 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, { useState } from "react"; +import useFormSubmit from "../../../../lib/form/submit"; +import { useCreateDomainPermissionSubscriptionMutation } from "../../../../lib/query/admin/domain-permissions/subscriptions"; +import { useBoolInput, useNumberInput, useTextInput } from "../../../../lib/form"; +import { urlValidator } from "../../../../lib/util/formvalidators"; +import MutationButton from "../../../../components/form/mutation-button"; +import { Checkbox, NumberInput, Select, TextInput } from "../../../../components/form/inputs"; +import { useLocation } from "wouter"; +import { DomainPermissionSubscriptionDocsLink, DomainPermissionSubscriptionHelpText } from "./common"; + +export default function DomainPermissionSubscriptionNew() { + const [ _location, setLocation ] = useLocation(); + + const useBasicAuth = useBoolInput("useBasicAuth", { defaultValue: false }); + const form = { + priority: useNumberInput("priority", { defaultValue: 0 }), + uri: useTextInput("uri", { + validator: urlValidator, + }), + content_type: useTextInput("content_type", { defaultValue: "text/csv" }), + permission_type: useTextInput("permission_type", { defaultValue: "block" }), + title: useTextInput("title"), + as_draft: useBoolInput("as_draft", { defaultValue: true }), + adopt_orphans: useBoolInput("adopt_orphans", { defaultValue: false }), + fetch_username: useTextInput("fetch_username", { + nosubmit: !useBasicAuth.value + }), + fetch_password: useTextInput("fetch_password", { + nosubmit: !useBasicAuth.value + }), + }; + + const [ showPassword, setShowPassword ] = useState(false); + + const [formSubmit, result] = useFormSubmit( + form, + useCreateDomainPermissionSubscriptionMutation(), + { + changedOnly: false, + onFinish: (res) => { + if (res.data) { + // Creation successful, + // redirect to subscription detail. + setLocation(`/subscriptions/${res.data.id}`); + } + }, + }); + + const submitDisabled = () => { + // URI required. + if (!form.uri.value || !form.uri.valid) { + return true; + } + + // If no basic auth, we don't care what + // fetch_password and fetch_username are. + if (!useBasicAuth.value) { + return false; + } + + // Either of fetch_password or fetch_username must be set. + return !(form.fetch_password.value || form.fetch_username.value); + }; + + return ( +
+
+

New Domain Permission Subscription

+

+ +
+ + + + + + + + + + + } + /> + + + <>Use + basic auth + <> when fetching + + } + field={useBasicAuth} + /> + + { useBasicAuth.value && + <> + +
+ + +
+ + } + + + + + + { !form.as_draft.value && +
+ + + Unchecking "create permissions as drafts" means that permissions found on the + subscribed list will be enforced immediately the next time the list is fetched. +
+ If you're subscribing to a block list, this means that blocks will be created + automatically from the given list, potentially severing any existing follow + relationships with accounts on the blocked domain. +
+ Before saving, make sure this is what you really want to do, and consider + creating domain excludes for domains that you want to manage manually. +
+
+ } + + + + ); +} diff --git a/web/source/settings/views/moderation/domain-permissions/subscriptions/preview.tsx b/web/source/settings/views/moderation/domain-permissions/subscriptions/preview.tsx new file mode 100644 index 000000000..a23c18c9e --- /dev/null +++ b/web/source/settings/views/moderation/domain-permissions/subscriptions/preview.tsx @@ -0,0 +1,100 @@ +/* + 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 } from "react"; + +import { useTextInput } from "../../../../lib/form"; +import { PageableList } from "../../../../components/pageable-list"; +import { useLocation } from "wouter"; +import { useGetDomainPermissionSubscriptionsPreviewQuery } from "../../../../lib/query/admin/domain-permissions/subscriptions"; +import { DomainPermSub } from "../../../../lib/types/domain-permission"; +import { Select } from "../../../../components/form/inputs"; +import { DomainPermissionSubscriptionDocsLink, SubscriptionListEntry } from "./common"; +import { PermType } from "../../../../lib/types/perm"; + +export default function DomainPermissionSubscriptionsPreview() { + return ( +
+
+

Domain Permission Subscriptions Preview

+

+ You can use the form below to view through domain permission subscriptions sorted by priority (high to low). +
+ This reflects the order in which they will actually be fetched by your instance, with higher-priority subscriptions + creating permissions first, followed by lower-priority subscriptions. +

+ +
+ +
+ ); +} + +function DomainPermissionSubscriptionsPreviewForm() { + const [ location, _setLocation ] = useLocation(); + + const permType = useTextInput("permission_type", { defaultValue: "block" }); + const { + data: permSubs, + isLoading, + isFetching, + isSuccess, + isError, + error, + } = useGetDomainPermissionSubscriptionsPreviewQuery(permType.value as PermType); + + // Function to map an item to a list entry. + function itemToEntry(permSub: DomainPermSub): ReactNode { + return ( + + ); + } + + return ( + <> +
+ +
+ No {permType.value}list subscriptions found.} + /> + + ); +} diff --git a/web/source/settings/views/moderation/menu.tsx b/web/source/settings/views/moderation/menu.tsx index 7ac6f9327..17b2f18e0 100644 --- a/web/source/settings/views/moderation/menu.tsx +++ b/web/source/settings/views/moderation/menu.tsx @@ -150,6 +150,28 @@ function ModerationDomainPermsMenu() { icon="fa-plus" /> + + + + + ); } diff --git a/web/source/settings/views/moderation/router.tsx b/web/source/settings/views/moderation/router.tsx index 779498ffe..90214188f 100644 --- a/web/source/settings/views/moderation/router.tsx +++ b/web/source/settings/views/moderation/router.tsx @@ -35,6 +35,10 @@ import DomainPermissionDraftDetail from "./domain-permissions/drafts/detail"; import DomainPermissionExcludeDetail from "./domain-permissions/excludes/detail"; import DomainPermissionExcludesSearch from "./domain-permissions/excludes"; import DomainPermissionExcludeNew from "./domain-permissions/excludes/new"; +import DomainPermissionSubscriptionsSearch from "./domain-permissions/subscriptions"; +import DomainPermissionSubscriptionNew from "./domain-permissions/subscriptions/new"; +import DomainPermissionSubscriptionDetail from "./domain-permissions/subscriptions/detail"; +import DomainPermissionSubscriptionsPreview from "./domain-permissions/subscriptions/preview"; /* EXPORTED COMPONENTS @@ -151,6 +155,10 @@ function ModerationDomainPermsRouter() { + + + +