[feature] Create/update/remove domain permission subscriptions

This commit is contained in:
tobi 2024-12-14 15:01:58 +01:00
parent 8504043024
commit a3743f8234
44 changed files with 4389 additions and 171 deletions

View file

@ -1130,6 +1130,100 @@ definitions:
type: object type: object
x-go-name: DomainPermission x-go-name: DomainPermission
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model 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: emoji:
properties: properties:
category: category:
@ -6050,6 +6144,335 @@ paths:
summary: Get domain permission exclude with the given ID. summary: Get domain permission exclude with the given ID.
tags: tags:
- admin - 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:
```
<https://example.org/api/v1/admin/domain_permission_subscriptions?limit=20&max_id=01FC0SKA48HNSVR6YKZCQGS2V8>; rel="next", <https://example.org/api/v1/admin/domain_permission_subscriptions?limit=20&min_id=01FC0SKW5JK2Q4EVAV2B462YY0>; rel="prev"
````
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: /api/v1/admin/email/test:
post: post:
consumes: consumes:

View file

@ -42,6 +42,10 @@
DomainPermissionDraftRemovePath = DomainPermissionDraftsPathWithID + "/remove" DomainPermissionDraftRemovePath = DomainPermissionDraftsPathWithID + "/remove"
DomainPermissionExcludesPath = BasePath + "/domain_permission_excludes" DomainPermissionExcludesPath = BasePath + "/domain_permission_excludes"
DomainPermissionExcludesPathWithID = DomainPermissionExcludesPath + "/:" + apiutil.IDKey DomainPermissionExcludesPathWithID = DomainPermissionExcludesPath + "/:" + apiutil.IDKey
DomainPermissionSubscriptionsPath = BasePath + "/domain_permission_subscriptions"
DomainPermissionSubscriptionsPathWithID = DomainPermissionSubscriptionsPath + "/:" + apiutil.IDKey
DomainPermissionSubscriptionsPreviewPath = DomainPermissionSubscriptionsPath + "/preview"
DomainPermissionSubscriptionRemovePath = DomainPermissionSubscriptionsPathWithID + "/remove"
DomainKeysExpirePath = BasePath + "/domain_keys_expire" DomainKeysExpirePath = BasePath + "/domain_keys_expire"
HeaderAllowsPath = BasePath + "/header_allows" HeaderAllowsPath = BasePath + "/header_allows"
HeaderAllowsPathWithID = HeaderAllowsPath + "/:" + apiutil.IDKey HeaderAllowsPathWithID = HeaderAllowsPath + "/:" + apiutil.IDKey
@ -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.MethodGet, DomainPermissionExcludesPathWithID, m.DomainPermissionExcludeGETHandler)
attachHandler(http.MethodDelete, DomainPermissionExcludesPathWithID, m.DomainPermissionExcludeDELETEHandler) 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 // header filtering administration routes
attachHandler(http.MethodGet, HeaderAllowsPathWithID, m.HeaderFilterAllowGET) attachHandler(http.MethodGet, HeaderAllowsPathWithID, m.HeaderFilterAllowGET)
attachHandler(http.MethodGet, HeaderBlocksPathWithID, m.HeaderFilterBlockGET) attachHandler(http.MethodGet, HeaderBlocksPathWithID, m.HeaderFilterBlockGET)

View file

@ -302,3 +302,45 @@ func (m *Module) getDomainPermissions(
apiutil.JSON(c, http.StatusOK, domainPerm) 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
}

View file

@ -26,7 +26,6 @@
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/oauth"
) )
@ -136,24 +135,8 @@ func (m *Module) DomainPermissionDraftsPOSTHandler(c *gin.Context) {
return return
} }
var ( permType, errWithCode := parseDomainPermissionType(form.PermissionType)
permType gtsmodel.DomainPermissionType if errWithCode != nil {
errText string
)
switch pt := form.PermissionType; pt {
case "block":
permType = gtsmodel.DomainPermissionBlock
case "allow":
permType = gtsmodel.DomainPermissionAllow
case "":
errText = "permission_type not set, must be one of block or allow"
default:
errText = fmt.Sprintf("permission_type %s not recognized, must be one of block or allow", pt)
}
if errText != "" {
errWithCode := gtserror.NewErrorBadRequest(errors.New(errText), errText)
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return return
} }

View file

@ -149,7 +149,7 @@ func (m *Module) DomainPermissionDraftsGETHandler(c *gin.Context) {
permTypeStr := c.Query(apiutil.DomainPermissionPermTypeKey) permTypeStr := c.Query(apiutil.DomainPermissionPermTypeKey)
permType := gtsmodel.ParseDomainPermissionType(permTypeStr) permType := gtsmodel.ParseDomainPermissionType(permTypeStr)
if permType == gtsmodel.DomainPermissionUnknown { if permTypeStr != "" && permType == gtsmodel.DomainPermissionUnknown {
text := fmt.Sprintf( text := fmt.Sprintf(
"permission_type %s not recognized, valid values are empty string, block, or allow", "permission_type %s not recognized, valid values are empty string, block, or allow",
permTypeStr, permTypeStr,

View file

@ -0,0 +1,244 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package admin
import (
"errors"
"fmt"
"net/http"
"net/url"
"github.com/gin-gonic/gin"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
// DomainPermissionSubscriptionPOSTHandler swagger:operation POST /api/v1/admin/domain_permission_subscriptions domainPermissionSubscriptionCreate
//
// Create a domain permission subscription with the given parameters.
//
// ---
// tags:
// - admin
//
// consumes:
// - multipart/form-data
// - application/json
//
// produces:
// - application/json
//
// parameters:
// -
// name: priority
// in: formData
// description: >-
// Priority of this subscription compared to others of the same permission type.
// 0-255 (higher = higher priority). Higher priority subscriptions will overwrite
// permissions generated by lower priority subscriptions. When two subscriptions
// have the same `priority` value, priority is indeterminate, so it's recommended
// to always set this value manually.
// type: number
// minimum: 0
// maximum: 255
// default: 0
// -
// name: title
// in: formData
// description: Optional title for this subscription.
// type: string
// -
// name: permission_type
// required: true
// in: formData
// description: >-
// Type of permissions to create by parsing the targeted file/list.
// One of "allow" or "block".
// type: string
// -
// name: as_draft
// in: formData
// description: >-
// If true, domain permissions arising from this subscription will be
// created as drafts that must be approved by a moderator to take effect.
// If false, domain permissions from this subscription will come into force immediately.
// Defaults to "true".
// type: boolean
// default: true
// -
// name: adopt_orphans
// in: formData
// description: >-
// If true, this domain permission subscription will "adopt" domain permissions
// which already exist on the instance, and which meet the following conditions:
// 1) they have no subscription ID (ie., they're "orphaned") and 2) they are present
// in the subscribed list. Such orphaned domain permissions will be given this
// subscription's subscription ID value and be managed by this subscription.
// type: boolean
// default: false
// -
// name: uri
// required: true
// in: formData
// description: URI to call in order to fetch the permissions list.
// type: string
// -
// name: content_type
// required: true
// in: formData
// description: >-
// MIME content type to use when parsing the permissions list.
// One of "text/plain", "text/csv", and "application/json".
// type: string
// -
// name: fetch_username
// in: formData
// description: >-
// Optional basic auth username to provide when fetching given uri.
// If set, will be transmitted along with `fetch_password` when doing the fetch.
// type: string
// -
// name: fetch_password
// in: formData
// description: >-
// Optional basic auth password to provide when fetching given uri.
// If set, will be transmitted along with `fetch_username` when doing the fetch.
// type: string
//
// security:
// - OAuth2 Bearer:
// - admin
//
// responses:
// '200':
// description: The newly created domain permission subscription.
// schema:
// "$ref": "#/definitions/domainPermissionSubscription"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '403':
// description: forbidden
// '406':
// description: not acceptable
// '409':
// description: conflict
// '500':
// description: internal server error
func (m *Module) DomainPermissionSubscriptionPOSTHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if !*authed.User.Admin {
err := fmt.Errorf("user %s not an admin", authed.User.ID)
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
// Parse + validate form.
form := new(apimodel.DomainPermissionSubscriptionRequest)
if err := c.ShouldBind(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
// Check priority.
// Default to 0.
priority := util.PtrOrZero(form.Priority)
if priority < 0 || priority > 255 {
const errText = "priority must be a number in the range 0 to 255"
errWithCode := gtserror.NewErrorBadRequest(errors.New(errText), errText)
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
// Ensure URI is set.
if form.URI == nil {
const errText = "uri must be set"
errWithCode := gtserror.NewErrorBadRequest(errors.New(errText), errText)
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
// Ensure URI is parseable.
uri, err := url.Parse(*form.URI)
if err != nil {
err := fmt.Errorf("invalid uri provided: %w", err)
errWithCode := gtserror.NewErrorBadRequest(err, err.Error())
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
// Normalize URI by converting back to string.
uriStr := uri.String()
// Content type must be set.
contentTypeStr := util.PtrOrZero(form.ContentType)
contentType, errWithCode := parseDomainPermSubContentType(contentTypeStr)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
// Permission type must be set.
permTypeStr := util.PtrOrZero(form.PermissionType)
permType, errWithCode := parseDomainPermissionType(permTypeStr)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
// Default `as_draft` to true.
asDraft := util.PtrOrValue(form.AsDraft, true)
permSub, errWithCode := m.processor.Admin().DomainPermissionSubscriptionCreate(
c.Request.Context(),
authed.Account,
uint8(priority), // #nosec G115 -- Validated above.
util.PtrOrZero(form.Title), // Optional.
uriStr,
contentType,
permType,
asDraft,
util.PtrOrZero(form.FetchUsername), // Optional.
util.PtrOrZero(form.FetchPassword), // Optional.
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, permSub)
}

View file

@ -0,0 +1,104 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package admin
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
// DomainPermissionSubscriptionGETHandler swagger:operation GET /api/v1/admin/domain_permission_subscriptions/{id} domainPermissionSubscriptionGet
//
// Get domain permission subscription with the given ID.
//
// ---
// tags:
// - admin
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// required: true
// in: path
// description: ID of the domain permission subscription.
// type: string
//
// security:
// - OAuth2 Bearer:
// - admin
//
// responses:
// '200':
// description: Domain permission subscription.
// schema:
// "$ref": "#/definitions/domainPermissionSubscription"
// '401':
// description: unauthorized
// '403':
// description: forbidden
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) DomainPermissionSubscriptionGETHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if !*authed.User.Admin {
err := fmt.Errorf("user %s not an admin", authed.User.ID)
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
permSub, errWithCode := m.processor.Admin().DomainPermissionSubscriptionGet(c.Request.Context(), id)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, permSub)
}

View file

@ -0,0 +1,143 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package admin
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
// DomainPermissionSubscriptionRemovePOSTHandler swagger:operation POST /api/v1/admin/domain_permission_subscriptions/{id}/remove domainPermissionSubscriptionRemove
//
// Remove a domain permission subscription.
//
// ---
// tags:
// - admin
//
// consumes:
// - multipart/form-data
// - application/json
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// required: true
// in: path
// description: ID of the domain permission subscription.
// type: string
// -
// name: remove_children
// in: formData
// description: >-
// When removing the domain permission subscription, also
// remove children of this subscription, ie., domain permissions
// that are managed by this subscription. If false, then children
// will instead be orphaned but not removed.
//
// Note that removed permissions may end up being created again later
// by another domain permission subscription of lower priority than
// the removed subscription. Likewise, orphaned children may be later
// adopted by another subscription.
// type: boolean
// default: true
//
// security:
// - OAuth2 Bearer:
// - admin
//
// responses:
// '200':
// description: The removed domain permission subscription.
// schema:
// "$ref": "#/definitions/domainPermissionSubscription"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '403':
// description: forbidden
// '406':
// description: not acceptable
// '409':
// description: conflict
// '500':
// description: internal server error
func (m *Module) DomainPermissionSubscriptionRemovePOSTHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if !*authed.User.Admin {
err := fmt.Errorf("user %s not an admin", authed.User.ID)
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
type RemoveForm struct {
RemoveChildren *bool `json:"remove_children" form:"remove_children"`
}
form := new(RemoveForm)
if err := c.ShouldBind(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
// Default removeChildren to true.
removeChildren := util.PtrOrValue(form.RemoveChildren, true)
permSub, errWithCode := m.processor.Admin().DomainPermissionSubscriptionRemove(
c.Request.Context(),
id,
removeChildren,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, permSub)
}

View file

@ -0,0 +1,177 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package admin
import (
"errors"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/paging"
)
// DomainPermissionSubscriptionsGETHandler swagger:operation GET /api/v1/admin/domain_permission_subscriptions domainPermissionSubscriptionsGet
//
// View domain permission subscriptions.
//
// The subscriptions will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer).
//
// The next and previous queries can be parsed from the returned Link header.
//
// Example:
//
// ```
// <https://example.org/api/v1/admin/domain_permission_subscriptions?limit=20&max_id=01FC0SKA48HNSVR6YKZCQGS2V8>; rel="next", <https://example.org/api/v1/admin/domain_permission_subscriptions?limit=20&min_id=01FC0SKW5JK2Q4EVAV2B462YY0>; rel="prev"
// ````
//
// ---
// tags:
// - admin
//
// produces:
// - application/json
//
// parameters:
// -
// name: permission_type
// type: string
// description: Filter on "block" or "allow" type subscriptions.
// in: query
// -
// name: max_id
// type: string
// description: >-
// Return only items *OLDER* than the given max ID (for paging downwards).
// The item with the specified ID will not be included in the response.
// in: query
// -
// name: since_id
// type: string
// description: >-
// Return only items *NEWER* than the given since ID.
// The item with the specified ID will not be included in the response.
// in: query
// -
// name: min_id
// type: string
// description: >-
// Return only items immediately *NEWER* than the given min ID (for paging upwards).
// The item with the specified ID will not be included in the response.
// in: query
// -
// name: limit
// type: integer
// description: Number of items to return.
// default: 20
// minimum: 1
// maximum: 100
// in: query
//
// security:
// - OAuth2 Bearer:
// - admin
//
// responses:
// '200':
// description: Domain permission subscriptions.
// schema:
// type: array
// items:
// "$ref": "#/definitions/domainPermissionSubscription"
// headers:
// Link:
// type: string
// description: Links to the next and previous queries.
// '400':
// description: bad request
// '401':
// description: unauthorized
// '403':
// description: forbidden
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) DomainPermissionSubscriptionsGETHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if !*authed.User.Admin {
err := fmt.Errorf("user %s not an admin", authed.User.ID)
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
permType := c.Query(apiutil.DomainPermissionPermTypeKey)
switch permType {
case "", "block", "allow":
// No problem.
default:
// Invalid.
text := fmt.Sprintf(
"permission_type %s not recognized, valid values are empty string, block, or allow",
permType,
)
errWithCode := gtserror.NewErrorBadRequest(errors.New(text), text)
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
page, errWithCode := paging.ParseIDPage(c, 1, 200, 20)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
resp, errWithCode := m.processor.Admin().DomainPermissionSubscriptionsGet(
c.Request.Context(),
gtsmodel.ParseDomainPermissionType(permType),
page,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
if resp.LinkHeader != "" {
c.Header("Link", resp.LinkHeader)
}
apiutil.JSON(c, http.StatusOK, resp.Items)
}

View file

@ -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 <http://www.gnu.org/licenses/>.
package admin
import (
"errors"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
// DomainPermissionSubscriptionsPreviewGETHandler swagger:operation GET /api/v1/admin/domain_permission_subscriptions/preview domainPermissionSubscriptionsPreviewGet
//
// View all domain permission subscriptions of the given permission type, in priority order (highest to lowest).
//
// This view allows you to see the order in which domain permissions will actually be fetched and created.
//
// ---
// tags:
// - admin
//
// produces:
// - application/json
//
// parameters:
// -
// name: permission_type
// type: string
// description: Filter on "block" or "allow" type subscriptions.
// in: query
// required: true
//
// security:
// - OAuth2 Bearer:
// - admin
//
// responses:
// '200':
// description: Domain permission subscriptions.
// schema:
// type: array
// items:
// "$ref": "#/definitions/domainPermissionSubscription"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '403':
// description: forbidden
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) DomainPermissionSubscriptionsPreviewGETHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if !*authed.User.Admin {
err := fmt.Errorf("user %s not an admin", authed.User.ID)
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
permType := c.Query(apiutil.DomainPermissionPermTypeKey)
switch permType {
case "block", "allow":
// No problem.
case "":
// Not set.
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)
}

View file

@ -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 <http://www.gnu.org/licenses/>.
package admin
import (
"errors"
"fmt"
"net/http"
"net/url"
"github.com/gin-gonic/gin"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
// DomainPermissionSubscriptionPATCHHandler swagger:operation PATCH /api/v1/admin/domain_permission_subscriptions/${id} domainPermissionSubscriptionUpdate
//
// Update a domain permission subscription with the given parameters.
//
// ---
// tags:
// - admin
//
// consumes:
// - multipart/form-data
// - application/json
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// required: true
// in: path
// description: ID of the domain permission subscription.
// type: string
// -
// name: priority
// in: formData
// description: >-
// Priority of this subscription compared to others of the same permission type.
// 0-255 (higher = higher priority). Higher priority subscriptions will overwrite
// permissions generated by lower priority subscriptions. When two subscriptions
// have the same `priority` value, priority is indeterminate, so it's recommended
// to always set this value manually.
// type: number
// minimum: 0
// maximum: 255
// -
// name: title
// in: formData
// description: Optional title for this subscription.
// type: string
// -
// name: uri
// in: formData
// description: URI to call in order to fetch the permissions list.
// type: string
// -
// name: as_draft
// in: formData
// description: >-
// If true, domain permissions arising from this subscription will be
// created as drafts that must be approved by a moderator to take effect.
// If false, domain permissions from this subscription will come into force immediately.
// Defaults to "true".
// type: boolean
// default: true
// -
// name: adopt_orphans
// in: formData
// description: >-
// If true, this domain permission subscription will "adopt" domain permissions
// which already exist on the instance, and which meet the following conditions:
// 1) they have no subscription ID (ie., they're "orphaned") and 2) they are present
// in the subscribed list. Such orphaned domain permissions will be given this
// subscription's subscription ID value and be managed by this subscription.
// type: boolean
// default: false
// -
// name: content_type
// in: formData
// description: >-
// MIME content type to use when parsing the permissions list.
// One of "text/plain", "text/csv", and "application/json".
// type: string
// -
// name: fetch_username
// in: formData
// description: >-
// Optional basic auth username to provide when fetching given uri.
// If set, will be transmitted along with `fetch_password` when doing the fetch.
// type: string
// -
// name: fetch_password
// in: formData
// description: >-
// Optional basic auth password to provide when fetching given uri.
// If set, will be transmitted along with `fetch_username` when doing the fetch.
// type: string
//
// security:
// - OAuth2 Bearer:
// - admin
//
// responses:
// '200':
// description: The updated domain permission subscription.
// schema:
// "$ref": "#/definitions/domainPermissionSubscription"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '403':
// description: forbidden
// '406':
// description: not acceptable
// '409':
// description: conflict
// '500':
// description: internal server error
func (m *Module) DomainPermissionSubscriptionPATCHHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if !*authed.User.Admin {
err := fmt.Errorf("user %s not an admin", authed.User.ID)
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
// Parse + validate form.
form := new(apimodel.DomainPermissionSubscriptionRequest)
if err := c.ShouldBind(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
// Normalize priority if set.
var priority *uint8
if form.Priority != nil {
prioInt := *form.Priority
if prioInt < 0 || prioInt > 255 {
const errText = "priority must be a number in the range 0 to 255"
errWithCode := gtserror.NewErrorBadRequest(errors.New(errText), errText)
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
priority = util.Ptr(uint8(prioInt)) // #nosec G115 -- Just validated.
}
// Validate URI if set.
var uriStr *string
if form.URI != nil {
uri, err := url.Parse(*form.URI)
if err != nil {
err := fmt.Errorf("invalid uri provided: %w", err)
errWithCode := gtserror.NewErrorBadRequest(err, err.Error())
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
// Normalize URI by converting back to string.
uriStr = util.Ptr(uri.String())
}
// Validate content type if set.
var contentType *gtsmodel.DomainPermSubContentType
if form.ContentType != nil {
ct, errWithCode := parseDomainPermSubContentType(*form.ContentType)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
contentType = &ct
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)
}

View file

@ -99,3 +99,101 @@ type DomainKeysExpireRequest struct {
// hostname/domain to expire keys for. // hostname/domain to expire keys for.
Domain string `form:"domain" json:"domain"` 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"`
}

View file

@ -75,6 +75,7 @@ func (c *Caches) Init() {
c.initDomainAllow() c.initDomainAllow()
c.initDomainBlock() c.initDomainBlock()
c.initDomainPermissionDraft() c.initDomainPermissionDraft()
c.initDomainPermissionSubscription()
c.initDomainPermissionExclude() c.initDomainPermissionExclude()
c.initEmoji() c.initEmoji()
c.initEmojiCategory() c.initEmojiCategory()

34
internal/cache/db.go vendored
View file

@ -70,6 +70,9 @@ type DBCaches struct {
// DomainPermissionDraft provides access to the domain permission draft database cache. // DomainPermissionDraft provides access to the domain permission draft database cache.
DomainPermissionDraft StructCache[*gtsmodel.DomainPermissionDraft] 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 provides access to the domain permission exclude database cache.
DomainPermissionExclude *domain.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() { func (c *Caches) initDomainPermissionExclude() {
c.DB.DomainPermissionExclude = new(domain.Cache) c.DB.DomainPermissionExclude = new(domain.Cache)
} }

View file

@ -357,6 +357,26 @@ func sizeofDomainPermissionDraft() uintptr {
})) }))
} }
func sizeofDomainPermissionSubscription() uintptr {
return uintptr(size.Of(&gtsmodel.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 { func sizeofEmoji() uintptr {
return uintptr(size.Of(&gtsmodel.Emoji{ return uintptr(size.Of(&gtsmodel.Emoji{
ID: exampleID, ID: exampleID,

View file

@ -209,6 +209,7 @@ type CacheConfiguration struct {
ConversationMemRatio float64 `name:"conversation-mem-ratio"` ConversationMemRatio float64 `name:"conversation-mem-ratio"`
ConversationLastStatusIDsMemRatio float64 `name:"conversation-last-status-ids-mem-ratio"` ConversationLastStatusIDsMemRatio float64 `name:"conversation-last-status-ids-mem-ratio"`
DomainPermissionDraftMemRation float64 `name:"domain-permission-draft-mem-ratio"` DomainPermissionDraftMemRation float64 `name:"domain-permission-draft-mem-ratio"`
DomainPermissionSubscriptionMemRation float64 `name:"domain-permission-subscription-mem-ratio"`
EmojiMemRatio float64 `name:"emoji-mem-ratio"` EmojiMemRatio float64 `name:"emoji-mem-ratio"`
EmojiCategoryMemRatio float64 `name:"emoji-category-mem-ratio"` EmojiCategoryMemRatio float64 `name:"emoji-category-mem-ratio"`
FilterMemRatio float64 `name:"filter-mem-ratio"` FilterMemRatio float64 `name:"filter-mem-ratio"`

View file

@ -170,6 +170,7 @@
ConversationMemRatio: 1, ConversationMemRatio: 1,
ConversationLastStatusIDsMemRatio: 2, ConversationLastStatusIDsMemRatio: 2,
DomainPermissionDraftMemRation: 0.5, DomainPermissionDraftMemRation: 0.5,
DomainPermissionSubscriptionMemRation: 0.5,
EmojiMemRatio: 3, EmojiMemRatio: 3,
EmojiCategoryMemRatio: 0.1, EmojiCategoryMemRatio: 0.1,
FilterMemRatio: 0.5, FilterMemRatio: 0.5,

View file

@ -3187,6 +3187,37 @@ func SetCacheDomainPermissionDraftMemRation(v float64) {
global.SetCacheDomainPermissionDraftMemRation(v) 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 // GetCacheEmojiMemRatio safely fetches the Configuration value for state's 'Cache.EmojiMemRatio' field
func (st *ConfigState) GetCacheEmojiMemRatio() (v float64) { func (st *ConfigState) GetCacheEmojiMemRatio() (v float64) {
st.mutex.RLock() st.mutex.RLock()

View file

@ -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 <http://www.gnu.org/licenses/>.
package bundb
import (
"context"
"errors"
"slices"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/uptrace/bun"
)
func (d *domainDB) getDomainPermissionSubscription(
ctx context.Context,
lookup string,
dbQuery func(*gtsmodel.DomainPermissionSubscription) error,
keyParts ...any,
) (*gtsmodel.DomainPermissionSubscription, error) {
// Fetch perm subscription from database cache with loader callback.
permSub, err := d.state.Caches.DB.DomainPermissionSubscription.LoadOne(
lookup,
// Only called if not cached.
func() (*gtsmodel.DomainPermissionSubscription, error) {
var permSub gtsmodel.DomainPermissionSubscription
if err := dbQuery(&permSub); err != nil {
return nil, err
}
return &permSub, nil
},
keyParts...,
)
if err != nil {
return nil, err
}
if gtscontext.Barebones(ctx) {
// No need to fully populate.
return permSub, nil
}
if permSub.CreatedByAccount == nil {
// Not set, fetch from database.
permSub.CreatedByAccount, err = d.state.DB.GetAccountByID(
gtscontext.SetBarebones(ctx),
permSub.CreatedByAccountID,
)
if err != nil {
return nil, gtserror.Newf("error populating created by account: %w", err)
}
}
return permSub, nil
}
func (d *domainDB) GetDomainPermissionSubscriptionByID(
ctx context.Context,
id string,
) (*gtsmodel.DomainPermissionSubscription, error) {
return d.getDomainPermissionSubscription(
ctx,
"ID",
func(permSub *gtsmodel.DomainPermissionSubscription) error {
return d.db.
NewSelect().
Model(permSub).
Where("? = ?", bun.Ident("domain_permission_subscription.id"), id).
Scan(ctx)
},
id,
)
}
func (d *domainDB) GetDomainPermissionSubscriptions(
ctx context.Context,
permType gtsmodel.DomainPermissionType,
page *paging.Page,
) (
[]*gtsmodel.DomainPermissionSubscription,
error,
) {
var (
// Get paging params.
minID = page.GetMin()
maxID = page.GetMax()
limit = page.GetLimit()
order = page.GetOrder()
// Make educated guess for slice size
permSubIDs = make([]string, 0, limit)
)
q := d.db.
NewSelect().
TableExpr(
"? AS ?",
bun.Ident("domain_permission_subscriptions"),
bun.Ident("domain_permission_subscription"),
).
// Select only IDs from table
Column("domain_permission_subscription.id")
// Return only items with id
// lower than provided maxID.
if maxID != "" {
q = q.Where(
"? < ?",
bun.Ident("domain_permission_subscription.id"),
maxID,
)
}
// Return only items with id
// greater than provided minID.
if minID != "" {
q = q.Where(
"? > ?",
bun.Ident("domain_permission_subscription.id"),
minID,
)
}
// Return only items with
// given permission type.
if permType != gtsmodel.DomainPermissionUnknown {
q = q.Where(
"? = ?",
bun.Ident("domain_permission_subscription.permission_type"),
permType,
)
}
if limit > 0 {
// Limit amount of
// items returned.
q = q.Limit(limit)
}
if order == paging.OrderAscending {
// Page up.
q = q.OrderExpr(
"? ASC",
bun.Ident("domain_permission_subscription.id"),
)
} else {
// Page down.
q = q.OrderExpr(
"? DESC",
bun.Ident("domain_permission_subscription.id"),
)
}
if err := q.Scan(ctx, &permSubIDs); err != nil {
return nil, err
}
// Catch case of no items early
if len(permSubIDs) == 0 {
return nil, db.ErrNoEntries
}
// If we're paging up, we still want items
// to be sorted by ID desc, so reverse slice.
if order == paging.OrderAscending {
slices.Reverse(permSubIDs)
}
// Allocate return slice (will be at most len permSubIDs).
permSubs := make([]*gtsmodel.DomainPermissionSubscription, 0, len(permSubIDs))
for _, id := range permSubIDs {
permSub, err := d.GetDomainPermissionSubscriptionByID(ctx, id)
if err != nil {
log.Errorf(ctx, "error getting domain permission subscription %q: %v", id, err)
continue
}
// Append to return slice
permSubs = append(permSubs, permSub)
}
return permSubs, nil
}
func (d *domainDB) GetDomainPermissionSubscriptionsByPriority(
ctx context.Context,
permType gtsmodel.DomainPermissionType,
) (
[]*gtsmodel.DomainPermissionSubscription,
error,
) {
permSubIDs := []string{}
q := d.db.
NewSelect().
TableExpr(
"? AS ?",
bun.Ident("domain_permission_subscriptions"),
bun.Ident("domain_permission_subscription"),
).
// Select only IDs from table
Column("domain_permission_subscription.id").
// Select only subs of given perm type.
Where(
"? = ?",
bun.Ident("domain_permission_subscription.permission_type"),
permType,
).
// Order by priority descending.
OrderExpr(
"? DESC",
bun.Ident("domain_permission_subscription.priority"),
)
if err := q.Scan(ctx, &permSubIDs); err != nil {
return nil, err
}
// Catch case of no items early
if len(permSubIDs) == 0 {
return nil, db.ErrNoEntries
}
// Allocate return slice (will be at most len permSubIDs).
permSubs := make([]*gtsmodel.DomainPermissionSubscription, 0, len(permSubIDs))
for _, id := range permSubIDs {
permSub, err := d.GetDomainPermissionSubscriptionByID(ctx, id)
if err != nil {
log.Errorf(ctx, "error getting domain permission subscription %q: %v", id, err)
continue
}
// Append to return slice
permSubs = append(permSubs, permSub)
}
return permSubs, nil
}
func (d *domainDB) PutDomainPermissionSubscription(
ctx context.Context,
permSubscription *gtsmodel.DomainPermissionSubscription,
) error {
return d.state.Caches.DB.DomainPermissionSubscription.Store(
permSubscription,
func() error {
_, err := d.db.
NewInsert().
Model(permSubscription).
Exec(ctx)
return err
},
)
}
func (d *domainDB) UpdateDomainPermissionSubscription(
ctx context.Context,
permSubscription *gtsmodel.DomainPermissionSubscription,
columns ...string,
) error {
return d.state.Caches.DB.DomainPermissionSubscription.Store(
permSubscription,
func() error {
_, err := d.db.
NewUpdate().
Model(permSubscription).
Where("? = ?", bun.Ident("id"), permSubscription.ID).
Column(columns...).
Exec(ctx)
return err
},
)
}
func (d *domainDB) DeleteDomainPermissionSubscription(
ctx context.Context,
id string,
) error {
// Delete the permSub from DB.
q := d.db.NewDelete().
TableExpr(
"? AS ?",
bun.Ident("domain_permission_subscriptions"),
bun.Ident("domain_permission_subscription"),
).
Where(
"? = ?",
bun.Ident("domain_permission_subscription.id"),
id,
)
_, err := q.Exec(ctx)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
return err
}
// Invalidate any cached model by ID.
d.state.Caches.DB.DomainPermissionSubscription.Invalidate("ID", id)
return nil
}

View file

@ -0,0 +1,75 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package migrations
import (
"context"
"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)
}
}

View file

@ -132,4 +132,40 @@ type Domain interface {
// IsDomainPermissionExcluded returns true if the given domain matches in the list of excluded domains. // IsDomainPermissionExcluded returns true if the given domain matches in the list of excluded domains.
IsDomainPermissionExcluded(ctx context.Context, domain string) (bool, error) 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
} }

View file

@ -0,0 +1,75 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package 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
}
}

View file

@ -83,3 +83,12 @@ func NewRandomULID() (string, error) {
} }
return newUlid.String(), nil 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
}

View file

@ -0,0 +1,285 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package admin
import (
"context"
"errors"
"fmt"
"net/url"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/paging"
)
// DomainPermissionSubscriptionGet returns one
// domain permission subscription with the given id.
func (p *Processor) DomainPermissionSubscriptionGet(
ctx context.Context,
id string,
) (*apimodel.DomainPermissionSubscription, gtserror.WithCode) {
permSub, err := p.state.DB.GetDomainPermissionSubscriptionByID(ctx, id)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error getting domain permission subscription %s: %w", id, err)
return nil, gtserror.NewErrorInternalError(err)
}
if permSub == nil {
err := fmt.Errorf("domain permission subscription %s not found", id)
return nil, gtserror.NewErrorNotFound(err, err.Error())
}
return p.apiDomainPermSub(ctx, permSub)
}
// DomainPermissionSubscriptionsGet returns a page of
// DomainPermissionSubscriptions with the given parameters.
func (p *Processor) DomainPermissionSubscriptionsGet(
ctx context.Context,
permType gtsmodel.DomainPermissionType,
page *paging.Page,
) (*apimodel.PageableResponse, gtserror.WithCode) {
permSubs, err := p.state.DB.GetDomainPermissionSubscriptions(
ctx,
permType,
page,
)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
count := len(permSubs)
if count == 0 {
return paging.EmptyResponse(), nil
}
// Get the lowest and highest
// ID values, used for paging.
lo := permSubs[count-1].ID
hi := permSubs[0].ID
// Convert each perm sub to API model.
items := make([]any, len(permSubs))
for i, permSub := range permSubs {
apiPermSub, err := p.converter.DomainPermSubToAPIDomainPermSub(ctx, permSub)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
items[i] = apiPermSub
}
// Assemble next/prev page queries.
query := make(url.Values, 1)
if permType != gtsmodel.DomainPermissionUnknown {
query.Set(apiutil.DomainPermissionPermTypeKey, permType.String())
}
return paging.PackageResponse(paging.ResponseParams{
Items: items,
Path: "/api/v1/admin/domain_permission_subscriptions",
Next: page.Next(lo, hi),
Prev: page.Prev(lo, hi),
Query: query,
}), nil
}
// DomainPermissionSubscriptionsGetByPriority returns all domain permission
// subscriptions of the given permission type, in descending priority order.
func (p *Processor) DomainPermissionSubscriptionsGetByPriority(
ctx context.Context,
permType gtsmodel.DomainPermissionType,
) ([]*apimodel.DomainPermissionSubscription, gtserror.WithCode) {
permSubs, err := p.state.DB.GetDomainPermissionSubscriptionsByPriority(
ctx,
permType,
)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
// Convert each perm sub to API model.
items := make([]*apimodel.DomainPermissionSubscription, len(permSubs))
for i, permSub := range permSubs {
apiPermSub, err := p.converter.DomainPermSubToAPIDomainPermSub(ctx, permSub)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
items[i] = apiPermSub
}
return items, nil
}
func (p *Processor) DomainPermissionSubscriptionCreate(
ctx context.Context,
acct *gtsmodel.Account,
priority uint8,
title string,
uri string,
contentType gtsmodel.DomainPermSubContentType,
permType gtsmodel.DomainPermissionType,
asDraft bool,
fetchUsername string,
fetchPassword string,
) (*apimodel.DomainPermissionSubscription, gtserror.WithCode) {
permSub := &gtsmodel.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)
}

View file

@ -115,3 +115,19 @@ func (p *Processor) apiDomainPerm(
return apiDomainPerm, nil 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
}

View file

@ -36,6 +36,7 @@
"github.com/superseriousbusiness/gotosocial/internal/filter/usermute" "github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
"github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/language" "github.com/superseriousbusiness/gotosocial/internal/language"
"github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/media"
@ -2002,6 +2003,54 @@ func (c *Converter) DomainPermToAPIDomainPerm(
return domainPerm, nil 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 // 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) { func (c *Converter) ReportToAPIReport(ctx context.Context, r *gtsmodel.Report) (*apimodel.Report, error) {
report := &apimodel.Report{ report := &apimodel.Report{

View file

@ -26,6 +26,7 @@ import type {
import type { import type {
FileFormInputHook, FileFormInputHook,
NumberFormInputHook,
RadioFormInputHook, RadioFormInputHook,
TextFormInputHook, TextFormInputHook,
} from "../../lib/form/types"; } from "../../lib/form/types";
@ -57,6 +58,32 @@ export function TextInput({label, field, ...props}: TextInputProps) {
); );
} }
export interface NumberInputProps extends React.DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
> {
label?: ReactNode;
field: NumberFormInputHook;
}
export function NumberInput({label, field, ...props}: NumberInputProps) {
const { onChange, value, ref } = field;
return (
<div className={`form-field number${field.valid ? "" : " invalid"}`}>
<label>
{label}
<input
onChange={onChange}
value={value}
ref={ref as RefObject<HTMLInputElement>}
{...props}
/>
</label>
</div>
);
}
export interface TextAreaProps extends React.DetailedHTMLProps< export interface TextAreaProps extends React.DetailedHTMLProps<
React.TextareaHTMLAttributes<HTMLTextAreaElement>, React.TextareaHTMLAttributes<HTMLTextAreaElement>,
HTMLTextAreaElement HTMLTextAreaElement

View file

@ -41,6 +41,7 @@ import type {
ChecklistInputHook, ChecklistInputHook,
FieldArrayInputHook, FieldArrayInputHook,
ArrayInputHook, ArrayInputHook,
NumberFormInputHook,
} from "./types"; } from "./types";
function capitalizeFirst(str: string) { function capitalizeFirst(str: string) {
@ -102,11 +103,11 @@ function value<T>(name: string, initialValue: T) {
name, name,
Name: "", Name: "",
value: initialValue, value: initialValue,
hasChanged: () => true, // always included
}; };
} }
export const useTextInput = inputHook(text) as (_name: string, _opts?: HookOpts<string>) => TextFormInputHook; export const useTextInput = inputHook(text) as (_name: string, _opts?: HookOpts<string>) => TextFormInputHook;
export const useNumberInput = inputHook(text) as (_name: string, _opts?: HookOpts<number>) => NumberFormInputHook;
export const useFileInput = inputHook(file) as (_name: string, _opts?: HookOpts<File>) => FileFormInputHook; export const useFileInput = inputHook(file) as (_name: string, _opts?: HookOpts<File>) => FileFormInputHook;
export const useBoolInput = inputHook(bool) as (_name: string, _opts?: HookOpts<boolean>) => BoolFormInputHook; export const useBoolInput = inputHook(bool) as (_name: string, _opts?: HookOpts<boolean>) => BoolFormInputHook;
export const useRadioInput = inputHook(radio) as (_name: string, _opts?: HookOpts<string>) => RadioFormInputHook; export const useRadioInput = inputHook(radio) as (_name: string, _opts?: HookOpts<string>) => RadioFormInputHook;

View file

@ -0,0 +1,104 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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<number>
): NumberFormInputHook {
const [number, setNumber] = useState(initialValue);
const numberRef = useRef<HTMLInputElement>(null);
const [validation, setValidation] = useState(initValidation ?? "");
const [_isValidating, startValidation] = useTransition();
const valid = validation == "";
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
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
});
}

View file

@ -34,8 +34,24 @@ import type {
} from "./types"; } from "./types";
interface UseFormSubmitOptions { interface UseFormSubmitOptions {
/**
* Include only changed fields when submitting the form.
* If no fields have been changed, submit will be a noop.
*/
changedOnly: boolean; 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); 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; usedAction.current = action;
// Transform the hooked form into an object. // Transform the hooked form into an object.
const { let {
mutationData, mutationData,
updatedFields, updatedFields,
} = getFormMutations(form, { changedOnly }); } = getFormMutations(form, { changedOnly });
@ -117,7 +133,12 @@ export default function useFormSubmit(
return; return;
} }
// Final tweaks on the mutation
// argument before triggering it.
mutationData.action = action; mutationData.action = action;
if (opts.customizeMutationArgs) {
mutationData = opts.customizeMutationArgs(mutationData);
}
try { try {
const res = await runMutation(mutationData); const res = await runMutation(mutationData);

View file

@ -181,6 +181,13 @@ export interface TextFormInputHook extends FormInputHook<string>,
_withValidate, _withValidate,
_withRef {} _withRef {}
export interface NumberFormInputHook extends FormInputHook<number>,
_withSetter<number>,
_withOnChange,
_withReset,
_withValidate,
_withRef {}
export interface RadioFormInputHook extends FormInputHook<string>, export interface RadioFormInputHook extends FormInputHook<string>,
_withSetter<string>, _withSetter<string>,
_withOnChange, _withOnChange,

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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<DomainPermSubSearchResp, DomainPermSubSearchParams>({
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<DomainPermSub[], PermType>({
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<DomainPermSub, string>({
query: (id) => ({
url: `/api/v1/admin/domain_permission_subscriptions/${id}`
}),
providesTags: (_result, _error, id) => [
{ type: 'DomainPermissionSubscription', id }
],
}),
createDomainPermissionSubscription: build.mutation<DomainPermSub, DomainPermSubCreateUpdateParams>({
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<DomainPermSub, { id: string, permType: PermType, formData: DomainPermSubCreateUpdateParams }>({
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<DomainPermSub, { id: string, remove_children: boolean }>({
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,
};

View file

@ -170,7 +170,8 @@ export const gtsApi = createApi({
"DefaultInteractionPolicies", "DefaultInteractionPolicies",
"InteractionRequest", "InteractionRequest",
"DomainPermissionDraft", "DomainPermissionDraft",
"DomainPermissionExclude" "DomainPermissionExclude",
"DomainPermissionSubscription"
], ],
endpoints: (build) => ({ endpoints: (build) => ({
instanceV1: build.query<InstanceV1, void>({ instanceV1: build.query<InstanceV1, void>({

View file

@ -20,6 +20,7 @@
import typia from "typia"; import typia from "typia";
import { PermType } from "./perm"; import { PermType } from "./perm";
import { Links } from "parse-link-header"; import { Links } from "parse-link-header";
import { PermSubContentType } from "./permsubcontenttype";
export const validateDomainPerms = typia.createValidate<DomainPerm[]>(); export const validateDomainPerms = typia.createValidate<DomainPerm[]>();
@ -213,3 +214,156 @@ export interface DomainPermExcludeCreateParams {
*/ */
private_comment?: string; 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;
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
export type PermSubContentType = "text/plain" | "text/csv" | "application/json";

View file

@ -46,3 +46,22 @@ export function formDomainValidator(domain: string): string {
return "invalid domain"; 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);
}

View file

@ -1360,9 +1360,12 @@ button.tab-button {
} }
.domain-permission-drafts-view, .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-draft,
.domain-permission-exclude { .domain-permission-exclude,
.domain-permission-subscription {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-wrap: nowrap; flex-wrap: nowrap;
@ -1404,14 +1407,18 @@ button.tab-button {
} }
.domain-permission-draft-details, .domain-permission-draft-details,
.domain-permission-exclude-details { .domain-permission-exclude-details,
.domain-permission-subscription-details {
.info-list { .info-list {
margin-top: 1rem; margin-top: 1rem;
} }
} }
.domain-permission-drafts-view, .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 { dd.permission-type {
display: flex; display: flex;
gap: 0.35rem; 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 { .instance-rules {
list-style-position: inside; list-style-position: inside;
margin: 0; margin: 0;

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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.
<br/>
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 (
<a
href="https://docs.gotosocial.org/en/latest/admin/settings/#domain-permission-subscriptions"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about domain permission subscriptions (opens in a new tab)
</a>
);
}
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 (
<span
className={`pseudolink domain-permission-subscription entry`}
aria-label={ariaLabel}
title={ariaLabel}
onClick={() => {
// 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}
>
<dl className="info-list">
{ permSub.title !== "" &&
<span className="domain-permission-subscription-title">
{title}
</span>
}
<div className="info-list-entry">
<dt>Priority:</dt>
<dd>{priority}</dd>
</div>
<div className="info-list-entry">
<dt>Permission type:</dt>
<dd className={`permission-type ${permType}`}>
<i
aria-hidden={true}
className={`fa fa-${permType === "allow" ? "check" : "close"}`}
></i>
{permType}
</dd>
</div>
<div className="info-list-entry">
<dt>URL:</dt>
<dd className="text-cutoff">{uri}</dd>
</div>
<div className="info-list-entry">
<dt>Content type:</dt>
<dd>{contentType}</dd>
</div>
<div className="info-list-entry">
<dt>Create as draft:</dt>
<dd>{yesOrNo(asDraft)}</dd>
</div>
<div className="info-list-entry">
<dt>Adopt orphans:</dt>
<dd>{yesOrNo(adoptOrphans)}</dd>
</div>
<div className="info-list-entry">
<dt>Last fetch attempt:</dt>
<dd className="text-cutoff">{fetchedAtStr}</dd>
</div>
<div className="info-list-entry">
<dt>Last successful fetch:</dt>
<dd className="text-cutoff">{successfullyFetchedAtStr}</dd>
</div>
<div className="info-list-entry">
<dt>Discovered {permType}s:</dt>
<dd>{count}</dd>
</div>
</dl>
</span>
);
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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 (
<FormWithData
dataQuery={useGetDomainPermissionSubscriptionQuery}
queryArg={id}
DataForm={DomainPermSubForm}
/>
);
}
function DomainPermSubForm({ data: permSub }: { data: DomainPermSub }) {
const baseUrl = useBaseUrl();
const backLocation: string = history.state?.backLocation ?? `~${baseUrl}/subscriptions/search`;
return (
<div className="domain-permission-subscription-details">
<h1><BackButton to={backLocation} /> Domain Permission Subscription Detail</h1>
<DomainPermSubDetails permSub={permSub} />
<UpdateDomainPermSub permSub={permSub} />
<DeleteDomainPermSub permSub={permSub} backLocation={backLocation} />
</div>
);
}
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 (
<dl className="info-list">
<div className="info-list-entry">
<dt>Permission type:</dt>
<dd className={`permission-type ${permType}`}>
<i
aria-hidden={true}
className={`fa fa-${permType === "allow" ? "check" : "close"}`}
></i>
{permType}
</dd>
</div>
<div className="info-list-entry">
<dt>ID</dt>
<dd className="monospace">{permSub.id}</dd>
</div>
<div className="info-list-entry">
<dt>Created</dt>
<dd><time dateTime={permSub.created_at}>{created}</time></dd>
</div>
<div className="info-list-entry">
<dt>Created By</dt>
<dd>
<UsernameLozenge
account={permSub.created_by}
linkTo={`~/settings/moderation/accounts/${permSub.created_by}`}
backLocation={`~${baseUrl}${location}`}
/>
</dd>
</div>
<div className="info-list-entry">
<dt>Last fetch attempt:</dt>
<dd>{fetchedAtStr}</dd>
</div>
<div className="info-list-entry">
<dt>Last successful fetch:</dt>
<dd>{successfullyFetchedAtStr}</dd>
</div>
<div className="info-list-entry">
<dt>Discovered {permSub.permission_type}s:</dt>
<dd>{permSub.count}</dd>
</div>
</dl>
);
}
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 (
<form
className="domain-permission-subscription-update"
onSubmit={submitUpdate}
// Prevent password managers
// trying to fill in fields.
autoComplete="off"
>
<h2>Edit Subscription</h2>
<TextInput
field={form.title}
label={`Subscription title`}
placeholder={`Some List of ${permSub.permission_type === "block" ? "Baddies" : "Goodies"}`}
autoCapitalize="words"
spellCheck="false"
/>
<NumberInput
field={form.priority}
label={`Subscription priority (0-255)`}
type="number"
min="0"
max="255"
/>
<TextInput
field={form.uri}
label={`Permission list URL (http or https)`}
placeholder="https://example.org/files/some_list_somewhere"
autoCapitalize="none"
spellCheck="false"
type="url"
/>
<Select
field={form.content_type}
label="Content type"
options={
<>
<option value="text/csv">CSV</option>
<option value="application/json">JSON</option>
<option value="text/plain">Plain</option>
</>
}
/>
<Checkbox
label={
<>
<>Use </>
<a
href="https://en.wikipedia.org/wiki/Basic_access_authentication"
target="_blank"
rel="noreferrer"
>basic auth</a>
<> when fetching</>
</>
}
field={form.useBasicAuth}
/>
{ form.useBasicAuth.value &&
<>
<TextInput
field={form.fetch_username}
label={`Basic auth username`}
autoCapitalize="none"
spellCheck="false"
autoComplete="off"
required={form.useBasicAuth.value && !form.fetch_password.value}
/>
<div className="password-show-hide">
<TextInput
field={form.fetch_password}
label={`Basic auth password`}
autoCapitalize="none"
spellCheck="false"
type={showPassword ? "" : "password"}
autoComplete="off"
required={form.useBasicAuth.value && !form.fetch_username.value}
/>
<button
className="password-show-hide-toggle"
type="button"
title={!showPassword ? "Show password" : "Hide password"}
onClick={e => {
e.preventDefault();
setShowPassword(!showPassword);
}}
>
{ !showPassword ? "Show" : "Hide" }
</button>
</div>
</>
}
<Checkbox
label="Adopt orphan permissions"
field={form.adopt_orphans}
/>
<Checkbox
label="Create permissions as drafts"
field={form.as_draft}
/>
{ !form.as_draft.value &&
<div className="info">
<i className="fa fa-fw fa-exclamation-circle" aria-hidden="true"></i>
<b>
Unchecking "create permissions as drafts" means that permissions found on the
subscribed list will be enforced immediately the next time the list is fetched.
<br/>
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.
<br/>
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.
</b>
</div>
}
<MutationButton
label="Save"
result={updateResult}
disabled={submitDisabled()}
/>
</form>
);
}
function DeleteDomainPermSub({ permSub, backLocation }: { permSub: DomainPermSub, backLocation: string }) {
const permType = permSub.permission_type;
if (!permType) {
throw "permission_type was undefined";
}
const [_location, setLocation] = useLocation();
const [ removeSub, result ] = useRemoveDomainPermissionSubscriptionMutation();
const removeChildren = useBoolInput("remove_children", { defaultValue: false });
return (
<form className="domain-permission-subscription-remove">
<h2>Remove Subscription</h2>
<Checkbox
label={`Also remove any ${permType}s created by this subscription`}
field={removeChildren}
/>
<MutationButton
label={`Remove`}
title={`Remove`}
type="button"
className="button danger"
onClick={(e) => {
e.preventDefault();
const id = permSub.id;
const remove_children = removeChildren.value as boolean;
removeSub({ id, remove_children }).then(res => {
if ("data" in res) {
setLocation(backLocation);
}
});
}}
disabled={false}
showError={true}
result={result}
/>
</form>
);
}

View file

@ -0,0 +1,170 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { ReactNode, useEffect, useMemo } from "react";
import { useTextInput } from "../../../../lib/form";
import { PageableList } from "../../../../components/pageable-list";
import MutationButton from "../../../../components/form/mutation-button";
import { useLocation, useSearch } from "wouter";
import { useLazySearchDomainPermissionSubscriptionsQuery } from "../../../../lib/query/admin/domain-permissions/subscriptions";
import { DomainPermSub } from "../../../../lib/types/domain-permission";
import { Select } from "../../../../components/form/inputs";
import { DomainPermissionSubscriptionDocsLink, DomainPermissionSubscriptionHelpText, SubscriptionListEntry } from "./common";
export default function DomainPermissionSubscriptionsSearch() {
return (
<div className="domain-permission-subscriptions-view">
<div className="form-section-docs">
<h1>Domain Permission Subscriptions</h1>
<p>
You can use the form below to search through domain permission
subscriptions, sorted by creation time (newer to older).
<br/>
<DomainPermissionSubscriptionHelpText />
</p>
<DomainPermissionSubscriptionDocsLink />
</div>
<DomainPermissionSubscriptionsSearchForm />
</div>
);
}
function DomainPermissionSubscriptionsSearchForm() {
const [ location, setLocation ] = useLocation();
const search = useSearch();
const urlQueryParams = useMemo(() => new URLSearchParams(search), [search]);
const hasParams = urlQueryParams.size != 0;
const [ searchSubscriptions, searchRes ] = useLazySearchDomainPermissionSubscriptionsQuery();
const form = {
permission_type: useTextInput("permission_type", { defaultValue: urlQueryParams.get("permission_type") ?? "" }),
limit: useTextInput("limit", { defaultValue: urlQueryParams.get("limit") ?? "20" })
};
// On mount, if urlQueryParams were provided,
// trigger the search. For example, if page
// was accessed at /search?origin=local&limit=20,
// then run a search with origin=local and
// limit=20 and immediately render the results.
//
// If no urlQueryParams set, trigger default
// search (first page, no filtering).
useEffect(() => {
if (hasParams) {
searchSubscriptions(Object.fromEntries(urlQueryParams));
} else {
setLocation(location + "?limit=20");
}
}, [
urlQueryParams,
hasParams,
searchSubscriptions,
location,
setLocation,
]);
// Rather than triggering the search directly,
// the "submit" button changes the location
// based on form field params, and lets the
// useEffect hook above actually do the search.
function submitQuery(e) {
e.preventDefault();
// Parse query parameters.
const entries = Object.entries(form).map(([k, v]) => {
// Take only defined form fields.
if (v.value === undefined || v.value.length === 0 || v.value === "any") {
return null;
}
return [[k, v.value]];
}).flatMap(kv => {
// Remove any nulls.
return kv || [];
});
const searchParams = new URLSearchParams(entries);
setLocation(location + "?" + searchParams.toString());
}
// Location to return to when user clicks "back" on the detail view.
const backLocation = location + (hasParams ? `?${urlQueryParams}` : "");
// Function to map an item to a list entry.
function itemToEntry(permSub: DomainPermSub): ReactNode {
return (
<SubscriptionListEntry
key={permSub.id}
permSub={permSub}
linkTo={`/subscriptions/${permSub.id}`}
backLocation={backLocation}
/>
);
}
return (
<>
<form
onSubmit={submitQuery}
// Prevent password managers
// trying to fill in fields.
autoComplete="off"
>
<Select
field={form.permission_type}
label="Permission type"
options={
<>
<option value="">Any</option>
<option value="block">Block</option>
<option value="allow">Allow</option>
</>
}
></Select>
<Select
field={form.limit}
label="Items per page"
options={
<>
<option value="20">20</option>
<option value="50">50</option>
<option value="100">100</option>
</>
}
></Select>
<MutationButton
disabled={false}
label={"Search"}
result={searchRes}
/>
</form>
<PageableList
isLoading={searchRes.isLoading}
isFetching={searchRes.isFetching}
isSuccess={searchRes.isSuccess}
items={searchRes.data?.subs}
itemToEntry={itemToEntry}
isError={searchRes.isError}
error={searchRes.error}
emptyMessage={<b>No subscriptions found that match your query.</b>}
prevNextLinks={searchRes.data?.links}
/>
</>
);
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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 (
<form
className="domain-permission-subscription-create"
onSubmit={formSubmit}
// Prevent password managers
// trying to fill in fields.
autoComplete="off"
>
<div className="form-section-docs">
<h2>New Domain Permission Subscription</h2>
<p><DomainPermissionSubscriptionHelpText /></p>
<DomainPermissionSubscriptionDocsLink />
</div>
<TextInput
field={form.title}
label={`Subscription title`}
placeholder={`Some List of ${form.permission_type.value === "block" ? "Baddies" : "Goodies"}`}
autoCapitalize="words"
spellCheck="false"
/>
<NumberInput
field={form.priority}
label={`Subscription priority (0-255)`}
type="number"
min="0"
max="255"
/>
<Select
field={form.permission_type}
label="Permission type"
options={
<>
<option value="block">Block</option>
<option value="allow">Allow</option>
</>
}
/>
<TextInput
field={form.uri}
label={`Permission list URL (http or https)`}
placeholder="https://example.org/files/some_list_somewhere"
autoCapitalize="none"
spellCheck="false"
type="url"
/>
<Select
field={form.content_type}
label="Content type"
options={
<>
<option value="text/csv">CSV</option>
<option value="application/json">JSON</option>
<option value="text/plain">Plain</option>
</>
}
/>
<Checkbox
label={
<>
<>Use </>
<a
href="https://en.wikipedia.org/wiki/Basic_access_authentication"
target="_blank"
rel="noreferrer"
>basic auth</a>
<> when fetching</>
</>
}
field={useBasicAuth}
/>
{ useBasicAuth.value &&
<>
<TextInput
field={form.fetch_username}
label={`Basic auth username`}
autoCapitalize="none"
spellCheck="false"
autoComplete="off"
required={useBasicAuth.value && !form.fetch_password.value}
/>
<div className="password-show-hide">
<TextInput
field={form.fetch_password}
label={`Basic auth password`}
autoCapitalize="none"
spellCheck="false"
type={showPassword ? "" : "password"}
autoComplete="off"
required={useBasicAuth.value && !form.fetch_username.value}
/>
<button
className="password-show-hide-toggle"
type="button"
title={!showPassword ? "Show password" : "Hide password"}
onClick={e => {
e.preventDefault();
setShowPassword(!showPassword);
}}
>
{ !showPassword ? "Show" : "Hide" }
</button>
</div>
</>
}
<Checkbox
label="Adopt orphan permissions"
field={form.adopt_orphans}
/>
<Checkbox
label="Create permissions as drafts"
field={form.as_draft}
/>
{ !form.as_draft.value &&
<div className="info">
<i className="fa fa-fw fa-exclamation-circle" aria-hidden="true"></i>
<b>
Unchecking "create permissions as drafts" means that permissions found on the
subscribed list will be enforced immediately the next time the list is fetched.
<br/>
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.
<br/>
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.
</b>
</div>
}
<MutationButton
label="Save"
result={result}
disabled={submitDisabled()}
/>
</form>
);
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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 (
<div className="domain-permission-subscriptions-preview">
<div className="form-section-docs">
<h1>Domain Permission Subscriptions Preview</h1>
<p>
You can use the form below to view through domain permission subscriptions sorted by priority (high to low).
<br/>
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.
</p>
<DomainPermissionSubscriptionDocsLink />
</div>
<DomainPermissionSubscriptionsPreviewForm />
</div>
);
}
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 (
<SubscriptionListEntry
key={permSub.id}
permSub={permSub}
linkTo={`/subscriptions/${permSub.id}`}
backLocation={location}
/>
);
}
return (
<>
<form>
<Select
field={permType}
label="Permission type"
options={
<>
<option value="block">Block</option>
<option value="allow">Allow</option>
</>
}
></Select>
</form>
<PageableList
isLoading={isLoading}
isFetching={isFetching}
isSuccess={isSuccess}
items={permSubs}
itemToEntry={itemToEntry}
isError={isError}
error={error}
emptyMessage={<b>No {permType.value}list subscriptions found.</b>}
/>
</>
);
}

View file

@ -150,6 +150,28 @@ function ModerationDomainPermsMenu() {
icon="fa-plus" icon="fa-plus"
/> />
</MenuItem> </MenuItem>
<MenuItem
name="Subscriptions"
itemUrl="subscriptions"
defaultChild="search"
icon="fa-cloud-download"
>
<MenuItem
name="Search"
itemUrl="search"
icon="fa-list"
/>
<MenuItem
name="New subscription"
itemUrl="new"
icon="fa-plus"
/>
<MenuItem
name="Preview"
itemUrl="preview"
icon="fa-eye"
/>
</MenuItem>
</MenuItem> </MenuItem>
); );
} }

View file

@ -35,6 +35,10 @@ import DomainPermissionDraftDetail from "./domain-permissions/drafts/detail";
import DomainPermissionExcludeDetail from "./domain-permissions/excludes/detail"; import DomainPermissionExcludeDetail from "./domain-permissions/excludes/detail";
import DomainPermissionExcludesSearch from "./domain-permissions/excludes"; import DomainPermissionExcludesSearch from "./domain-permissions/excludes";
import DomainPermissionExcludeNew from "./domain-permissions/excludes/new"; 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 EXPORTED COMPONENTS
@ -151,6 +155,10 @@ function ModerationDomainPermsRouter() {
<Route path="/excludes/search" component={DomainPermissionExcludesSearch} /> <Route path="/excludes/search" component={DomainPermissionExcludesSearch} />
<Route path="/excludes/new" component={DomainPermissionExcludeNew} /> <Route path="/excludes/new" component={DomainPermissionExcludeNew} />
<Route path="/excludes/:excludeId" component={DomainPermissionExcludeDetail} /> <Route path="/excludes/:excludeId" component={DomainPermissionExcludeDetail} />
<Route path="/subscriptions/search" component={DomainPermissionSubscriptionsSearch} />
<Route path="/subscriptions/new" component={DomainPermissionSubscriptionNew} />
<Route path="/subscriptions/preview" component={DomainPermissionSubscriptionsPreview} />
<Route path="/subscriptions/:permSubId" component={DomainPermissionSubscriptionDetail} />
<Route path="/:permType" component={DomainPermissionsOverview} /> <Route path="/:permType" component={DomainPermissionsOverview} />
<Route path="/:permType/:domain" component={DomainPermDetail} /> <Route path="/:permType/:domain" component={DomainPermDetail} />
<Route><Redirect to="/blocks"/></Route> <Route><Redirect to="/blocks"/></Route>