This commit is contained in:
tobi 2024-11-18 13:39:03 +00:00 committed by GitHub
commit 8cec6fd374
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 5586 additions and 264 deletions

View file

@ -1095,6 +1095,12 @@ definitions:
example: false
type: boolean
x-go-name: Obfuscate
permission_type:
description: |-
Permission type of this entry (block, allow).
Only set for domain permission drafts.
type: string
x-go-name: PermissionType
private_comment:
description: Private comment for this permission entry, visible to this instance's admins only.
example: they are poopoo
@ -5572,6 +5578,425 @@ paths:
summary: Force expiry of cached public keys for all accounts on the given domain stored in your database.
tags:
- admin
/api/v1/admin/domain_permission_drafts:
get:
description: |-
The drafts will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer).
The next and previous queries can be parsed from the returned Link header.
Example:
```
<https://example.org/api/v1/admin/domain_permission_drafts?limit=20&max_id=01FC0SKA48HNSVR6YKZCQGS2V8>; rel="next", <https://example.org/api/v1/admin/domain_permission_drafts?limit=20&min_id=01FC0SKW5JK2Q4EVAV2B462YY0>; rel="prev"
````
operationId: domainPermissionDraftsGet
parameters:
- description: Show only drafts created by the given subscription ID.
in: query
name: subscription_id
type: string
- description: Return only drafts that target the given domain.
in: query
name: domain
type: string
- description: Filter on "block" or "allow" type drafts.
in: query
name: permission_type
type: string
- description: Return only items *OLDER* than the given max ID (for paging downwards). The item with the specified ID will not be included in the response.
in: query
name: max_id
type: string
- description: Return only items *NEWER* than the given since ID. The item with the specified ID will not be included in the response.
in: query
name: since_id
type: string
- description: Return only items immediately *NEWER* than the given min ID (for paging upwards). The item with the specified ID will not be included in the response.
in: query
name: min_id
type: string
- default: 20
description: Number of items to return.
in: query
maximum: 100
minimum: 1
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: Domain permission drafts.
headers:
Link:
description: Links to the next and previous queries.
type: string
schema:
items:
$ref: '#/definitions/domainPermission'
type: array
"400":
description: bad request
"401":
description: unauthorized
"403":
description: forbidden
"404":
description: not found
"406":
description: not acceptable
"500":
description: internal server error
security:
- OAuth2 Bearer:
- admin
summary: View domain permission drafts.
tags:
- admin
post:
consumes:
- multipart/form-data
- application/json
operationId: domainPermissionDraftCreate
parameters:
- description: Domain to create the permission draft for.
in: formData
name: domain
type: string
- description: Create a draft "allow" or a draft "block".
in: formData
name: permission_type
type: string
- description: Obfuscate the name of the domain when serving it publicly. Eg., `example.org` becomes something like `ex***e.org`.
in: formData
name: obfuscate
type: boolean
- description: Public comment about this domain permission. This will be displayed alongside the domain permission if you choose to share permissions.
in: formData
name: public_comment
type: string
- description: Private comment about this domain permission. Will only be shown to other admins, so this is a useful way of internally keeping track of why a certain domain ended up permissioned.
in: formData
name: private_comment
type: string
produces:
- application/json
responses:
"200":
description: The newly created domain permission draft.
schema:
$ref: '#/definitions/domainPermission'
"400":
description: bad request
"401":
description: unauthorized
"403":
description: forbidden
"406":
description: not acceptable
"409":
description: conflict
"500":
description: internal server error
security:
- OAuth2 Bearer:
- admin
summary: Create a domain permission draft with the given parameters.
tags:
- admin
/api/v1/admin/domain_permission_drafts/{id}:
get:
operationId: domainPermissionDraftGet
parameters:
- description: ID of the domain permission draft.
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: Domain permission draft.
schema:
$ref: '#/definitions/domainPermission'
"401":
description: unauthorized
"403":
description: forbidden
"404":
description: not found
"406":
description: not acceptable
"500":
description: internal server error
security:
- OAuth2 Bearer:
- admin
summary: Get domain permission draft with the given ID.
tags:
- admin
/api/v1/admin/domain_permission_drafts/{id}/accept:
post:
consumes:
- multipart/form-data
- application/json
operationId: domainPermissionDraftAccept
parameters:
- description: ID of the domain permission draft.
in: path
name: id
required: true
type: string
- default: false
description: If a domain permission already exists with the same domain and permission type as the draft, overwrite the existing permission with fields from the draft.
in: formData
name: overwrite
type: boolean
produces:
- application/json
responses:
"200":
description: The newly created domain permission.
schema:
$ref: '#/definitions/domainPermission'
"400":
description: bad request
"401":
description: unauthorized
"403":
description: forbidden
"406":
description: not acceptable
"409":
description: conflict
"500":
description: internal server error
security:
- OAuth2 Bearer:
- admin
summary: Accept a domain permission draft, turning it into an enforced domain permission.
tags:
- admin
/api/v1/admin/domain_permission_drafts/{id}/remove:
post:
consumes:
- multipart/form-data
- application/json
operationId: domainPermissionDraftRemove
parameters:
- description: ID of the domain permission draft.
in: path
name: id
required: true
type: string
- default: false
description: When removing the domain permission draft, also create a domain exclude entry for the target domain, so that drafts will not be created for this domain in the future.
in: formData
name: exclude_target
type: boolean
produces:
- application/json
responses:
"200":
description: The removed domain permission draft.
schema:
$ref: '#/definitions/domainPermission'
"400":
description: bad request
"401":
description: unauthorized
"403":
description: forbidden
"406":
description: not acceptable
"409":
description: conflict
"500":
description: internal server error
security:
- OAuth2 Bearer:
- admin
summary: Remove a domain permission draft, optionally ignoring all future drafts targeting the given domain.
tags:
- admin
/api/v1/admin/domain_permission_excludes:
get:
description: |-
The excludes will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer).
The next and previous queries can be parsed from the returned Link header.
Example:
```
<https://example.org/api/v1/admin/domain_permission_excludes?limit=20&max_id=01FC0SKA48HNSVR6YKZCQGS2V8>; rel="next", <https://example.org/api/v1/admin/domain_permission_excludes?limit=20&min_id=01FC0SKW5JK2Q4EVAV2B462YY0>; rel="prev"
````
operationId: domainPermissionExcludesGet
parameters:
- description: Return only excludes that target the given domain.
in: query
name: domain
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 excludes.
headers:
Link:
description: Links to the next and previous queries.
type: string
schema:
items:
$ref: '#/definitions/domainPermission'
type: array
"400":
description: bad request
"401":
description: unauthorized
"403":
description: forbidden
"404":
description: not found
"406":
description: not acceptable
"500":
description: internal server error
security:
- OAuth2 Bearer:
- admin
summary: View domain permission excludes.
tags:
- admin
post:
consumes:
- multipart/form-data
- application/json
description: |-
Excluded domains (and their subdomains) will not be automatically blocked or allowed when a list of domain permissions is imported or subscribed to.
You can still manually create domain blocks or domain allows for excluded domains, and any new or existing domain blocks or domain allows for an excluded domain will still be enforced.
operationId: domainPermissionExcludeCreate
parameters:
- description: Domain to create the permission exclude for.
in: formData
name: domain
type: string
- description: Private comment about this domain exclude.
in: formData
name: private_comment
type: string
produces:
- application/json
responses:
"200":
description: The newly created domain permission exclude.
schema:
$ref: '#/definitions/domainPermission'
"400":
description: bad request
"401":
description: unauthorized
"403":
description: forbidden
"406":
description: not acceptable
"409":
description: conflict
"500":
description: internal server error
security:
- OAuth2 Bearer:
- admin
summary: Create a domain permission exclude with the given parameters.
tags:
- admin
/api/v1/admin/domain_permission_excludes/{id}:
delete:
operationId: domainPermissionExcludeDelete
parameters:
- description: ID of the domain permission exclude.
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: The removed domain permission exclude.
schema:
$ref: '#/definitions/domainPermission'
"400":
description: bad request
"401":
description: unauthorized
"403":
description: forbidden
"406":
description: not acceptable
"409":
description: conflict
"500":
description: internal server error
security:
- OAuth2 Bearer:
- admin
summary: Remove a domain permission exclude.
tags:
- admin
get:
operationId: domainPermissionExcludeGet
parameters:
- description: ID of the domain permission exclude.
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: Domain permission exclude.
schema:
$ref: '#/definitions/domainPermission'
"401":
description: unauthorized
"403":
description: forbidden
"404":
description: not found
"406":
description: not acceptable
"500":
description: internal server error
security:
- OAuth2 Bearer:
- admin
summary: Get domain permission exclude with the given ID.
tags:
- admin
/api/v1/admin/email/test:
post:
consumes:

View file

@ -28,37 +28,43 @@
)
const (
BasePath = "/v1/admin"
EmojiPath = BasePath + "/custom_emojis"
EmojiPathWithID = EmojiPath + "/:" + apiutil.IDKey
EmojiCategoriesPath = EmojiPath + "/categories"
DomainBlocksPath = BasePath + "/domain_blocks"
DomainBlocksPathWithID = DomainBlocksPath + "/:" + apiutil.IDKey
DomainAllowsPath = BasePath + "/domain_allows"
DomainAllowsPathWithID = DomainAllowsPath + "/:" + apiutil.IDKey
DomainKeysExpirePath = BasePath + "/domain_keys_expire"
HeaderAllowsPath = BasePath + "/header_allows"
HeaderAllowsPathWithID = HeaderAllowsPath + "/:" + apiutil.IDKey
HeaderBlocksPath = BasePath + "/header_blocks"
HeaderBlocksPathWithID = HeaderBlocksPath + "/:" + apiutil.IDKey
AccountsV1Path = BasePath + "/accounts"
AccountsV2Path = "/v2/admin/accounts"
AccountsPathWithID = AccountsV1Path + "/:" + apiutil.IDKey
AccountsActionPath = AccountsPathWithID + "/action"
AccountsApprovePath = AccountsPathWithID + "/approve"
AccountsRejectPath = AccountsPathWithID + "/reject"
MediaCleanupPath = BasePath + "/media_cleanup"
MediaRefetchPath = BasePath + "/media_refetch"
ReportsPath = BasePath + "/reports"
ReportsPathWithID = ReportsPath + "/:" + apiutil.IDKey
ReportsResolvePath = ReportsPathWithID + "/resolve"
EmailPath = BasePath + "/email"
EmailTestPath = EmailPath + "/test"
InstanceRulesPath = BasePath + "/instance/rules"
InstanceRulesPathWithID = InstanceRulesPath + "/:" + apiutil.IDKey
DebugPath = BasePath + "/debug"
DebugAPUrlPath = DebugPath + "/apurl"
DebugClearCachesPath = DebugPath + "/caches/clear"
BasePath = "/v1/admin"
EmojiPath = BasePath + "/custom_emojis"
EmojiPathWithID = EmojiPath + "/:" + apiutil.IDKey
EmojiCategoriesPath = EmojiPath + "/categories"
DomainBlocksPath = BasePath + "/domain_blocks"
DomainBlocksPathWithID = DomainBlocksPath + "/:" + apiutil.IDKey
DomainAllowsPath = BasePath + "/domain_allows"
DomainAllowsPathWithID = DomainAllowsPath + "/:" + apiutil.IDKey
DomainPermissionDraftsPath = BasePath + "/domain_permission_drafts"
DomainPermissionDraftsPathWithID = DomainPermissionDraftsPath + "/:" + apiutil.IDKey
DomainPermissionDraftAcceptPath = DomainPermissionDraftsPathWithID + "/accept"
DomainPermissionDraftRemovePath = DomainPermissionDraftsPathWithID + "/remove"
DomainPermissionExcludesPath = BasePath + "/domain_permission_excludes"
DomainPermissionExcludesPathWithID = DomainPermissionExcludesPath + "/:" + apiutil.IDKey
DomainKeysExpirePath = BasePath + "/domain_keys_expire"
HeaderAllowsPath = BasePath + "/header_allows"
HeaderAllowsPathWithID = HeaderAllowsPath + "/:" + apiutil.IDKey
HeaderBlocksPath = BasePath + "/header_blocks"
HeaderBlocksPathWithID = HeaderBlocksPath + "/:" + apiutil.IDKey
AccountsV1Path = BasePath + "/accounts"
AccountsV2Path = "/v2/admin/accounts"
AccountsPathWithID = AccountsV1Path + "/:" + apiutil.IDKey
AccountsActionPath = AccountsPathWithID + "/action"
AccountsApprovePath = AccountsPathWithID + "/approve"
AccountsRejectPath = AccountsPathWithID + "/reject"
MediaCleanupPath = BasePath + "/media_cleanup"
MediaRefetchPath = BasePath + "/media_refetch"
ReportsPath = BasePath + "/reports"
ReportsPathWithID = ReportsPath + "/:" + apiutil.IDKey
ReportsResolvePath = ReportsPathWithID + "/resolve"
EmailPath = BasePath + "/email"
EmailTestPath = EmailPath + "/test"
InstanceRulesPath = BasePath + "/instance/rules"
InstanceRulesPathWithID = InstanceRulesPath + "/:" + apiutil.IDKey
DebugPath = BasePath + "/debug"
DebugAPUrlPath = DebugPath + "/apurl"
DebugClearCachesPath = DebugPath + "/caches/clear"
FilterQueryKey = "filter"
MaxShortcodeDomainKey = "max_shortcode_domain"
@ -99,6 +105,19 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H
attachHandler(http.MethodGet, DomainAllowsPathWithID, m.DomainAllowGETHandler)
attachHandler(http.MethodDelete, DomainAllowsPathWithID, m.DomainAllowDELETEHandler)
// domain permission draft stuff
attachHandler(http.MethodPost, DomainPermissionDraftsPath, m.DomainPermissionDraftsPOSTHandler)
attachHandler(http.MethodGet, DomainPermissionDraftsPath, m.DomainPermissionDraftsGETHandler)
attachHandler(http.MethodGet, DomainPermissionDraftsPathWithID, m.DomainPermissionDraftGETHandler)
attachHandler(http.MethodPost, DomainPermissionDraftAcceptPath, m.DomainPermissionDraftAcceptPOSTHandler)
attachHandler(http.MethodPost, DomainPermissionDraftRemovePath, m.DomainPermissionDraftRemovePOSTHandler)
// domain permission excludes stuff
attachHandler(http.MethodPost, DomainPermissionExcludesPath, m.DomainPermissionExcludesPOSTHandler)
attachHandler(http.MethodGet, DomainPermissionExcludesPath, m.DomainPermissionExcludesGETHandler)
attachHandler(http.MethodGet, DomainPermissionExcludesPathWithID, m.DomainPermissionExcludeGETHandler)
attachHandler(http.MethodDelete, DomainPermissionExcludesPathWithID, m.DomainPermissionExcludeDELETEHandler)
// header filtering administration routes
attachHandler(http.MethodGet, HeaderAllowsPathWithID, m.HeaderFilterAllowGET)
attachHandler(http.MethodGet, HeaderBlocksPathWithID, m.HeaderFilterBlockGET)

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"
)
// DomainPermissionDraftGETHandler swagger:operation GET /api/v1/admin/domain_permission_drafts/{id} domainPermissionDraftGet
//
// Get domain permission draft with the given ID.
//
// ---
// tags:
// - admin
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// required: true
// in: path
// description: ID of the domain permission draft.
// type: string
//
// security:
// - OAuth2 Bearer:
// - admin
//
// responses:
// '200':
// description: Domain permission draft.
// schema:
// "$ref": "#/definitions/domainPermission"
// '401':
// description: unauthorized
// '403':
// description: forbidden
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) DomainPermissionDraftGETHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if !*authed.User.Admin {
err := fmt.Errorf("user %s not an admin", authed.User.ID)
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
permDraft, errWithCode := m.processor.Admin().DomainPermissionDraftGet(c.Request.Context(), id)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, permDraft)
}

View file

@ -0,0 +1,134 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package admin
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
// DomainPermissionDraftAcceptPOSTHandler swagger:operation POST /api/v1/admin/domain_permission_drafts/{id}/accept domainPermissionDraftAccept
//
// Accept a domain permission draft, turning it into an enforced domain permission.
//
// ---
// tags:
// - admin
//
// consumes:
// - multipart/form-data
// - application/json
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// required: true
// in: path
// description: ID of the domain permission draft.
// type: string
// -
// name: overwrite
// in: formData
// description: >-
// If a domain permission already exists with the same
// domain and permission type as the draft, overwrite
// the existing permission with fields from the draft.
// type: boolean
// default: false
//
// security:
// - OAuth2 Bearer:
// - admin
//
// responses:
// '200':
// description: The newly created domain permission.
// schema:
// "$ref": "#/definitions/domainPermission"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '403':
// description: forbidden
// '406':
// description: not acceptable
// '409':
// description: conflict
// '500':
// description: internal server error
func (m *Module) DomainPermissionDraftAcceptPOSTHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if !*authed.User.Admin {
err := fmt.Errorf("user %s not an admin", authed.User.ID)
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
type AcceptForm struct {
Overwrite bool `json:"overwrite" form:"overwrite"`
}
form := new(AcceptForm)
if err := c.ShouldBind(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
domainPerm, _, errWithCode := m.processor.Admin().DomainPermissionDraftAccept(
c.Request.Context(),
authed.Account,
id,
form.Overwrite,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, domainPerm)
}

View file

@ -0,0 +1,176 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package admin
import (
"errors"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
// DomainPermissionDraftsPOSTHandler swagger:operation POST /api/v1/admin/domain_permission_drafts domainPermissionDraftCreate
//
// Create a domain permission draft with the given parameters.
//
// ---
// tags:
// - admin
//
// consumes:
// - multipart/form-data
// - application/json
//
// produces:
// - application/json
//
// parameters:
// -
// name: domain
// in: formData
// description: Domain to create the permission draft for.
// type: string
// -
// name: permission_type
// in: formData
// description: Create a draft "allow" or a draft "block".
// type: string
// -
// name: obfuscate
// in: formData
// description: >-
// Obfuscate the name of the domain when serving it publicly.
// Eg., `example.org` becomes something like `ex***e.org`.
// type: boolean
// -
// name: public_comment
// in: formData
// description: >-
// Public comment about this domain permission.
// This will be displayed alongside the domain permission if you choose to share permissions.
// type: string
// -
// name: private_comment
// in: formData
// description: >-
// Private comment about this domain permission. Will only be shown to other admins, so this
// is a useful way of internally keeping track of why a certain domain ended up permissioned.
// type: string
//
// security:
// - OAuth2 Bearer:
// - admin
//
// responses:
// '200':
// description: The newly created domain permission draft.
// schema:
// "$ref": "#/definitions/domainPermission"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '403':
// description: forbidden
// '406':
// description: not acceptable
// '409':
// description: conflict
// '500':
// description: internal server error
func (m *Module) DomainPermissionDraftsPOSTHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if !*authed.User.Admin {
err := fmt.Errorf("user %s not an admin", authed.User.ID)
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
// Parse + validate form.
form := new(apimodel.DomainPermissionRequest)
if err := c.ShouldBind(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
if form.Domain == "" {
const errText = "domain must be set"
errWithCode := gtserror.NewErrorBadRequest(errors.New(errText), errText)
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
var (
permType gtsmodel.DomainPermissionType
errText string
)
switch pt := form.PermissionType; pt {
case "block":
permType = gtsmodel.DomainPermissionBlock
case "allow":
permType = gtsmodel.DomainPermissionAllow
case "":
errText = "permission_type not set, must be one of block or allow"
default:
errText = fmt.Sprintf("permission_type %s not recognized, must be one of block or allow", pt)
}
if errText != "" {
errWithCode := gtserror.NewErrorBadRequest(errors.New(errText), errText)
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
permDraft, errWithCode := m.processor.Admin().DomainPermissionDraftCreate(
c.Request.Context(),
authed.Account,
form.Domain,
permType,
form.Obfuscate,
form.PublicComment,
form.PrivateComment,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, permDraft)
}

View file

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

View file

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

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"
)
// DomainPermissionExcludeGETHandler swagger:operation GET /api/v1/admin/domain_permission_excludes/{id} domainPermissionExcludeGet
//
// Get domain permission exclude with the given ID.
//
// ---
// tags:
// - admin
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// required: true
// in: path
// description: ID of the domain permission exclude.
// type: string
//
// security:
// - OAuth2 Bearer:
// - admin
//
// responses:
// '200':
// description: Domain permission exclude.
// schema:
// "$ref": "#/definitions/domainPermission"
// '401':
// description: unauthorized
// '403':
// description: forbidden
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) DomainPermissionExcludeGETHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if !*authed.User.Admin {
err := fmt.Errorf("user %s not an admin", authed.User.ID)
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
permExclude, errWithCode := m.processor.Admin().DomainPermissionExcludeGet(c.Request.Context(), id)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, permExclude)
}

View file

@ -0,0 +1,138 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package admin
import (
"errors"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
// DomainPermissionExcludesPOSTHandler swagger:operation POST /api/v1/admin/domain_permission_excludes domainPermissionExcludeCreate
//
// Create a domain permission exclude with the given parameters.
//
// Excluded domains (and their subdomains) will not be automatically blocked or allowed when a list of domain permissions is imported or subscribed to.
//
// You can still manually create domain blocks or domain allows for excluded domains, and any new or existing domain blocks or domain allows for an excluded domain will still be enforced.
//
// ---
// tags:
// - admin
//
// consumes:
// - multipart/form-data
// - application/json
//
// produces:
// - application/json
//
// parameters:
// -
// name: domain
// in: formData
// description: Domain to create the permission exclude for.
// type: string
// -
// name: private_comment
// in: formData
// description: >-
// Private comment about this domain exclude.
// type: string
//
// security:
// - OAuth2 Bearer:
// - admin
//
// responses:
// '200':
// description: The newly created domain permission exclude.
// schema:
// "$ref": "#/definitions/domainPermission"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '403':
// description: forbidden
// '406':
// description: not acceptable
// '409':
// description: conflict
// '500':
// description: internal server error
func (m *Module) DomainPermissionExcludesPOSTHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if !*authed.User.Admin {
err := fmt.Errorf("user %s not an admin", authed.User.ID)
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
// Parse + validate form.
type ExcludeForm struct {
Domain string `form:"domain" json:"domain"`
PrivateComment string `form:"private_comment" json:"private_comment"`
}
form := new(ExcludeForm)
if err := c.ShouldBind(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
if form.Domain == "" {
const errText = "domain must be set"
errWithCode := gtserror.NewErrorBadRequest(errors.New(errText), errText)
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
permExclude, errWithCode := m.processor.Admin().DomainPermissionExcludeCreate(
c.Request.Context(),
authed.Account,
form.Domain,
form.PrivateComment,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, permExclude)
}

View file

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

View file

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

View file

@ -61,6 +61,9 @@ type DomainPermission struct {
// Time at which the permission entry was created (ISO 8601 Datetime).
// example: 2021-07-30T09:20:25+00:00
CreatedAt string `json:"created_at,omitempty"`
// Permission type of this entry (block, allow).
// Only set for domain permission drafts.
PermissionType string `json:"permission_type,omitempty"`
}
// DomainPermissionRequest is the form submitted as a POST to create a new domain permission entry (allow/block).
@ -69,22 +72,24 @@ type DomainPermission struct {
type DomainPermissionRequest struct {
// A list of domains for which this permission request should apply.
// Only used if import=true is specified.
Domains *multipart.FileHeader `form:"domains" json:"domains" xml:"domains"`
Domains *multipart.FileHeader `form:"domains" json:"domains"`
// A single domain for which this permission request should apply.
// Only used if import=true is NOT specified or if import=false.
// example: example.org
Domain string `form:"domain" json:"domain" xml:"domain"`
Domain string `form:"domain" json:"domain"`
// Obfuscate the domain name when displaying this permission entry publicly.
// Ie., instead of 'example.org' show something like 'e**mpl*.or*'.
// example: false
Obfuscate bool `form:"obfuscate" json:"obfuscate" xml:"obfuscate"`
Obfuscate bool `form:"obfuscate" json:"obfuscate"`
// Private comment for other admins on why this permission entry was created.
// example: don't like 'em!!!!
PrivateComment string `form:"private_comment" json:"private_comment" xml:"private_comment"`
PrivateComment string `form:"private_comment" json:"private_comment"`
// Public comment on why this permission entry was created.
// Will be visible to requesters at /api/v1/instance/peers if this endpoint is exposed.
// example: foss dorks 😫
PublicComment string `form:"public_comment" json:"public_comment" xml:"public_comment"`
PublicComment string `form:"public_comment" json:"public_comment"`
// Permission type to create (only applies to domain permission drafts, not explicit blocks and allows).
PermissionType string `form:"permission_type" json:"permission_type"`
}
// DomainKeysExpireRequest is the form submitted as a POST to /api/v1/admin/domain_keys_expire to expire a domain's public keys.
@ -92,5 +97,5 @@ type DomainPermissionRequest struct {
// swagger:parameters domainKeysExpire
type DomainKeysExpireRequest struct {
// hostname/domain to expire keys for.
Domain string `form:"domain" json:"domain" xml:"domain"`
Domain string `form:"domain" json:"domain"`
}

View file

@ -69,8 +69,11 @@
/* Domain permission keys */
DomainPermissionExportKey = "export"
DomainPermissionImportKey = "import"
DomainPermissionExportKey = "export"
DomainPermissionImportKey = "import"
DomainPermissionSubscriptionIDKey = "subscription_id"
DomainPermissionPermTypeKey = "permission_type"
DomainPermissionDomainKey = "domain"
/* Admin query keys */

View file

@ -74,6 +74,8 @@ func (c *Caches) Init() {
c.initConversationLastStatusIDs()
c.initDomainAllow()
c.initDomainBlock()
c.initDomainPermissionDraft()
c.initDomainPermissionExclude()
c.initEmoji()
c.initEmojiCategory()
c.initFilter()

42
internal/cache/db.go vendored
View file

@ -67,6 +67,12 @@ type DBCaches struct {
// DomainBlock provides access to the domain block database cache.
DomainBlock *domain.Cache
// DomainPermissionDraft provides access to the domain permission draft database cache.
DomainPermissionDraft StructCache[*gtsmodel.DomainPermissionDraft]
// DomainPermissionExclude provides access to the domain permission exclude database cache.
DomainPermissionExclude *domain.Cache
// Emoji provides access to the gtsmodel Emoji database cache.
Emoji StructCache[*gtsmodel.Emoji]
@ -548,6 +554,42 @@ func (c *Caches) initDomainBlock() {
c.DB.DomainBlock = new(domain.Cache)
}
func (c *Caches) initDomainPermissionDraft() {
// Calculate maximum cache size.
cap := calculateResultCacheMax(
sizeofDomainPermissionDraft(), // model in-mem size.
config.GetCacheDomainPermissionDraftMemRation(),
)
log.Infof(nil, "cache size = %d", cap)
copyF := func(d1 *gtsmodel.DomainPermissionDraft) *gtsmodel.DomainPermissionDraft {
d2 := new(gtsmodel.DomainPermissionDraft)
*d2 = *d1
// Don't include ptr fields that
// will be populated separately.
d2.CreatedByAccount = nil
return d2
}
c.DB.DomainPermissionDraft.Init(structr.CacheConfig[*gtsmodel.DomainPermissionDraft]{
Indices: []structr.IndexConfig{
{Fields: "ID"},
{Fields: "Domain", Multiple: true},
{Fields: "SubscriptionID", Multiple: true},
},
MaxSize: cap,
IgnoreErr: ignoreErrors,
Copy: copyF,
})
}
func (c *Caches) initDomainPermissionExclude() {
c.DB.DomainPermissionExclude = new(domain.Cache)
}
func (c *Caches) initEmoji() {
// Calculate maximum cache size.
cap := calculateResultCacheMax(

View file

@ -342,6 +342,21 @@ func sizeofConversation() uintptr {
}))
}
func sizeofDomainPermissionDraft() uintptr {
return uintptr(size.Of(&gtsmodel.DomainPermissionDraft{
ID: exampleID,
CreatedAt: exampleTime,
UpdatedAt: exampleTime,
PermissionType: gtsmodel.DomainPermissionBlock,
Domain: "example.org",
CreatedByAccountID: exampleID,
PrivateComment: exampleTextSmall,
PublicComment: exampleTextSmall,
Obfuscate: util.Ptr(false),
SubscriptionID: exampleID,
}))
}
func sizeofEmoji() uintptr {
return uintptr(size.Of(&gtsmodel.Emoji{
ID: exampleID,

View file

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

View file

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

View file

@ -3156,6 +3156,37 @@ func SetCacheConversationLastStatusIDsMemRatio(v float64) {
global.SetCacheConversationLastStatusIDsMemRatio(v)
}
// GetCacheDomainPermissionDraftMemRation safely fetches the Configuration value for state's 'Cache.DomainPermissionDraftMemRation' field
func (st *ConfigState) GetCacheDomainPermissionDraftMemRation() (v float64) {
st.mutex.RLock()
v = st.config.Cache.DomainPermissionDraftMemRation
st.mutex.RUnlock()
return
}
// SetCacheDomainPermissionDraftMemRation safely sets the Configuration value for state's 'Cache.DomainPermissionDraftMemRation' field
func (st *ConfigState) SetCacheDomainPermissionDraftMemRation(v float64) {
st.mutex.Lock()
defer st.mutex.Unlock()
st.config.Cache.DomainPermissionDraftMemRation = v
st.reloadToViper()
}
// CacheDomainPermissionDraftMemRationFlag returns the flag name for the 'Cache.DomainPermissionDraftMemRation' field
func CacheDomainPermissionDraftMemRationFlag() string {
return "cache-domain-permission-draft-mem-ratio"
}
// GetCacheDomainPermissionDraftMemRation safely fetches the value for global configuration 'Cache.DomainPermissionDraftMemRation' field
func GetCacheDomainPermissionDraftMemRation() float64 {
return global.GetCacheDomainPermissionDraftMemRation()
}
// SetCacheDomainPermissionDraftMemRation safely sets the value for global configuration 'Cache.DomainPermissionDraftMemRation' field
func SetCacheDomainPermissionDraftMemRation(v float64) {
global.SetCacheDomainPermissionDraftMemRation(v)
}
// GetCacheEmojiMemRatio safely fetches the Configuration value for state's 'Cache.EmojiMemRatio' field
func (st *ConfigState) GetCacheEmojiMemRatio() (v float64) {
st.mutex.RLock()

View file

@ -20,6 +20,7 @@
import (
"context"
"net/url"
"time"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
@ -110,6 +111,36 @@ func (d *domainDB) GetDomainAllowByID(ctx context.Context, id string) (*gtsmodel
return &allow, nil
}
func (d *domainDB) UpdateDomainAllow(ctx context.Context, allow *gtsmodel.DomainAllow, columns ...string) error {
// Normalize the domain as punycode
var err error
allow.Domain, err = util.Punify(allow.Domain)
if err != nil {
return err
}
// Ensure updated_at is set.
allow.UpdatedAt = time.Now()
if len(columns) != 0 {
columns = append(columns, "updated_at")
}
// Attempt to update domain allow.
if _, err := d.db.
NewUpdate().
Model(allow).
Column(columns...).
Where("? = ?", bun.Ident("domain_allow.id"), allow.ID).
Exec(ctx); err != nil {
return err
}
// Clear the domain allow cache (for later reload)
d.state.Caches.DB.DomainAllow.Clear()
return nil
}
func (d *domainDB) DeleteDomainAllow(ctx context.Context, domain string) error {
// Normalize the domain as punycode
domain, err := util.Punify(domain)
@ -206,6 +237,36 @@ func (d *domainDB) GetDomainBlockByID(ctx context.Context, id string) (*gtsmodel
return &block, nil
}
func (d *domainDB) UpdateDomainBlock(ctx context.Context, block *gtsmodel.DomainBlock, columns ...string) error {
// Normalize the domain as punycode
var err error
block.Domain, err = util.Punify(block.Domain)
if err != nil {
return err
}
// Ensure updated_at is set.
block.UpdatedAt = time.Now()
if len(columns) != 0 {
columns = append(columns, "updated_at")
}
// Attempt to update domain block.
if _, err := d.db.
NewUpdate().
Model(block).
Column(columns...).
Where("? = ?", bun.Ident("domain_block.id"), block.ID).
Exec(ctx); err != nil {
return err
}
// Clear the domain block cache (for later reload)
d.state.Caches.DB.DomainBlock.Clear()
return nil
}
func (d *domainDB) DeleteDomainBlock(ctx context.Context, domain string) error {
// Normalize the domain as punycode
domain, err := util.Punify(domain)

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 bundb
import (
"context"
"errors"
"slices"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/uptrace/bun"
)
func (d *domainDB) getDomainPermissionDraft(
ctx context.Context,
lookup string,
dbQuery func(*gtsmodel.DomainPermissionDraft) error,
keyParts ...any,
) (*gtsmodel.DomainPermissionDraft, error) {
// Fetch perm draft from database cache with loader callback.
permDraft, err := d.state.Caches.DB.DomainPermissionDraft.LoadOne(
lookup,
// Only called if not cached.
func() (*gtsmodel.DomainPermissionDraft, error) {
var permDraft gtsmodel.DomainPermissionDraft
if err := dbQuery(&permDraft); err != nil {
return nil, err
}
return &permDraft, nil
},
keyParts...,
)
if err != nil {
return nil, err
}
if gtscontext.Barebones(ctx) {
// No need to fully populate.
return permDraft, nil
}
if permDraft.CreatedByAccount == nil {
// Not set, fetch from database.
permDraft.CreatedByAccount, err = d.state.DB.GetAccountByID(
gtscontext.SetBarebones(ctx),
permDraft.CreatedByAccountID,
)
if err != nil {
return nil, gtserror.Newf("error populating created by account: %w", err)
}
}
return permDraft, nil
}
func (d *domainDB) GetDomainPermissionDraftByID(
ctx context.Context,
id string,
) (*gtsmodel.DomainPermissionDraft, error) {
return d.getDomainPermissionDraft(
ctx,
"ID",
func(permDraft *gtsmodel.DomainPermissionDraft) error {
return d.db.
NewSelect().
Model(permDraft).
Where("? = ?", bun.Ident("domain_permission_draft.id"), id).
Scan(ctx)
},
id,
)
}
func (d *domainDB) GetDomainPermissionDrafts(
ctx context.Context,
permType gtsmodel.DomainPermissionType,
permSubID string,
domain string,
page *paging.Page,
) (
[]*gtsmodel.DomainPermissionDraft,
error,
) {
var (
// Get paging params.
minID = page.GetMin()
maxID = page.GetMax()
limit = page.GetLimit()
order = page.GetOrder()
// Make educated guess for slice size
permDraftIDs = make([]string, 0, limit)
)
q := d.db.
NewSelect().
TableExpr(
"? AS ?",
bun.Ident("domain_permission_drafts"),
bun.Ident("domain_permission_draft"),
).
// Select only IDs from table
Column("domain_permission_draft.id")
// Return only items with id
// lower than provided maxID.
if maxID != "" {
q = q.Where(
"? < ?",
bun.Ident("domain_permission_draft.id"),
maxID,
)
}
// Return only items with id
// greater than provided minID.
if minID != "" {
q = q.Where(
"? > ?",
bun.Ident("domain_permission_draft.id"),
minID,
)
}
// Return only items with
// given permission type.
if permType != gtsmodel.DomainPermissionUnknown {
q = q.Where(
"? = ?",
bun.Ident("domain_permission_draft.permission_type"),
permType,
)
}
// Return only items with
// given subscription ID.
if permSubID != "" {
q = q.Where(
"? = ?",
bun.Ident("domain_permission_draft.subscription_id"),
permSubID,
)
}
// Return only items
// with given domain.
if domain != "" {
var err error
// Normalize domain as punycode.
domain, err = util.Punify(domain)
if err != nil {
return nil, gtserror.Newf("error punifying domain %s: %w", domain, err)
}
q = q.Where(
"? = ?",
bun.Ident("domain_permission_draft.domain"),
domain,
)
}
if limit > 0 {
// Limit amount of
// items returned.
q = q.Limit(limit)
}
if order == paging.OrderAscending {
// Page up.
q = q.OrderExpr(
"? ASC",
bun.Ident("domain_permission_draft.id"),
)
} else {
// Page down.
q = q.OrderExpr(
"? DESC",
bun.Ident("domain_permission_draft.id"),
)
}
if err := q.Scan(ctx, &permDraftIDs); err != nil {
return nil, err
}
// Catch case of no items early
if len(permDraftIDs) == 0 {
return nil, db.ErrNoEntries
}
// If we're paging up, we still want items
// to be sorted by ID desc, so reverse slice.
if order == paging.OrderAscending {
slices.Reverse(permDraftIDs)
}
// Allocate return slice (will be at most len permDraftIDs)
permDrafts := make([]*gtsmodel.DomainPermissionDraft, 0, len(permDraftIDs))
for _, id := range permDraftIDs {
permDraft, err := d.GetDomainPermissionDraftByID(ctx, id)
if err != nil {
log.Errorf(ctx, "error getting domain permission draft %q: %v", id, err)
continue
}
// Append to return slice
permDrafts = append(permDrafts, permDraft)
}
return permDrafts, nil
}
func (d *domainDB) PutDomainPermissionDraft(
ctx context.Context,
permDraft *gtsmodel.DomainPermissionDraft,
) error {
var err error
// Normalize the domain as punycode
permDraft.Domain, err = util.Punify(permDraft.Domain)
if err != nil {
return gtserror.Newf("error punifying domain %s: %w", permDraft.Domain, err)
}
return d.state.Caches.DB.DomainPermissionDraft.Store(
permDraft,
func() error {
_, err := d.db.
NewInsert().
Model(permDraft).
Exec(ctx)
return err
},
)
}
func (d *domainDB) DeleteDomainPermissionDraft(
ctx context.Context,
id string,
) error {
// Delete the permDraft from DB.
q := d.db.NewDelete().
TableExpr(
"? AS ?",
bun.Ident("domain_permission_drafts"),
bun.Ident("domain_permission_draft"),
).
Where(
"? = ?",
bun.Ident("domain_permission_draft.id"),
id,
)
_, err := q.Exec(ctx)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
return err
}
// Invalidate any cached model by ID.
d.state.Caches.DB.DomainPermissionDraft.Invalidate("ID", id)
return nil
}

View file

@ -0,0 +1,120 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package bundb_test
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
type DomainPermissionDraftTestSuite struct {
BunDBStandardTestSuite
}
func (suite *DomainPermissionDraftTestSuite) TestPermDraftCreateGetDelete() {
var (
ctx = context.Background()
draft = &gtsmodel.DomainPermissionDraft{
ID: "01JCZN614XG85GCGAMSV9ZZAEJ",
PermissionType: gtsmodel.DomainPermissionBlock,
Domain: "exämple.org",
CreatedByAccountID: suite.testAccounts["admin_account"].ID,
PrivateComment: "this domain is poo",
PublicComment: "this domain is poo, but phrased in a more outward-facing way",
Obfuscate: util.Ptr(false),
SubscriptionID: "01JCZN8PG55KKEVTDAY52D0T3P",
}
)
// Whack the draft in.
if err := suite.state.DB.PutDomainPermissionDraft(ctx, draft); err != nil {
suite.FailNow(err.Error())
}
// Get the draft again.
dbDraft, err := suite.state.DB.GetDomainPermissionDraftByID(ctx, draft.ID)
if err != nil {
suite.FailNow(err.Error())
}
// Domain should have been stored punycoded.
suite.Equal("xn--exmple-cua.org", dbDraft.Domain)
// Search for domain using both
// punycode and unicode variants.
search1, err := suite.state.DB.GetDomainPermissionDrafts(
ctx,
gtsmodel.DomainPermissionUnknown,
"",
"exämple.org",
nil,
)
if err != nil {
suite.FailNow(err.Error())
}
if len(search1) != 1 {
suite.FailNow("couldn't get domain perm draft exämple.org")
}
search2, err := suite.state.DB.GetDomainPermissionDrafts(
ctx,
gtsmodel.DomainPermissionUnknown,
"",
"xn--exmple-cua.org",
nil,
)
if err != nil {
suite.FailNow(err.Error())
}
if len(search2) != 1 {
suite.FailNow("couldn't get domain perm draft example.org")
}
// Change ID + try to put the same draft again.
draft.ID = "01JCZNVYSDT3JE385FABMJ7ADQ"
err = suite.state.DB.PutDomainPermissionDraft(ctx, draft)
if !errors.Is(err, db.ErrAlreadyExists) {
suite.FailNow("was able to insert same domain perm draft twice")
}
// Put same draft but change permission type, should work.
draft.PermissionType = gtsmodel.DomainPermissionAllow
if err := suite.state.DB.PutDomainPermissionDraft(ctx, draft); err != nil {
suite.FailNow(err.Error())
}
// Delete both drafts.
for _, id := range []string{
"01JCZN614XG85GCGAMSV9ZZAEJ",
"01JCZNVYSDT3JE385FABMJ7ADQ",
} {
if err := suite.state.DB.DeleteDomainPermissionDraft(ctx, id); err != nil {
suite.FailNow("error deleting domain permission draft")
}
}
}
func TestDomainPermissionDraftTestSuite(t *testing.T) {
suite.Run(t, new(DomainPermissionDraftTestSuite))
}

View file

@ -0,0 +1,275 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package bundb
import (
"context"
"errors"
"slices"
"github.com/miekg/dns"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/uptrace/bun"
)
func (d *domainDB) PutDomainPermissionExclude(
ctx context.Context,
exclude *gtsmodel.DomainPermissionExclude,
) error {
// Normalize the domain as punycode
var err error
exclude.Domain, err = util.Punify(exclude.Domain)
if err != nil {
return err
}
// Attempt to store domain perm exclude in DB
if _, err := d.db.NewInsert().
Model(exclude).
Exec(ctx); err != nil {
return err
}
// Clear the domain perm exclude cache (for later reload)
d.state.Caches.DB.DomainPermissionExclude.Clear()
return nil
}
func (d *domainDB) IsDomainPermissionExcluded(ctx context.Context, domain string) (bool, error) {
// Normalize the domain as punycode
domain, err := util.Punify(domain)
if err != nil {
return false, err
}
// Check if our host and given domain are equal
// or part of the same second-level domain; we
// always exclude such perms as creating blocks
// or allows in such cases may break things.
if dns.CompareDomainName(domain, config.GetHost()) >= 2 {
return true, nil
}
// Func to scan list of all
// excluded domain perms from DB.
loadF := func() ([]string, error) {
var domains []string
if err := d.db.
NewSelect().
Table("domain_excludes").
Column("domain").
Scan(ctx, &domains); err != nil {
return nil, err
}
return domains, nil
}
// Check the cache for a domain perm exclude,
// hydrating the cache with loadF if necessary.
return d.state.Caches.DB.DomainPermissionExclude.Matches(domain, loadF)
}
func (d *domainDB) GetDomainPermissionExcludeByID(
ctx context.Context,
id string,
) (*gtsmodel.DomainPermissionExclude, error) {
exclude := new(gtsmodel.DomainPermissionExclude)
q := d.db.
NewSelect().
Model(exclude).
Where("? = ?", bun.Ident("domain_permission_exclude.id"), id)
if err := q.Scan(ctx); err != nil {
return nil, err
}
if gtscontext.Barebones(ctx) {
// No need to fully populate.
return exclude, nil
}
if exclude.CreatedByAccount == nil {
// Not set, fetch from database.
var err error
exclude.CreatedByAccount, err = d.state.DB.GetAccountByID(
gtscontext.SetBarebones(ctx),
exclude.CreatedByAccountID,
)
if err != nil {
return nil, gtserror.Newf("error populating created by account: %w", err)
}
}
return exclude, nil
}
func (d *domainDB) GetDomainPermissionExcludes(
ctx context.Context,
domain string,
page *paging.Page,
) (
[]*gtsmodel.DomainPermissionExclude,
error,
) {
var (
// Get paging params.
minID = page.GetMin()
maxID = page.GetMax()
limit = page.GetLimit()
order = page.GetOrder()
// Make educated guess for slice size
excludeIDs = make([]string, 0, limit)
)
q := d.db.
NewSelect().
TableExpr(
"? AS ?",
bun.Ident("domain_permission_excludes"),
bun.Ident("domain_permission_exclude"),
).
// Select only IDs from table
Column("domain_permission_exclude.id")
// Return only items with id
// lower than provided maxID.
if maxID != "" {
q = q.Where(
"? < ?",
bun.Ident("domain_permission_exclude.id"),
maxID,
)
}
// Return only items with id
// greater than provided minID.
if minID != "" {
q = q.Where(
"? > ?",
bun.Ident("domain_permission_exclude.id"),
minID,
)
}
// Return only items
// with given domain.
if domain != "" {
var err error
// Normalize domain as punycode.
domain, err = util.Punify(domain)
if err != nil {
return nil, gtserror.Newf("error punifying domain %s: %w", domain, err)
}
q = q.Where(
"? = ?",
bun.Ident("domain_permission_exclude.domain"),
domain,
)
}
if limit > 0 {
// Limit amount of
// items returned.
q = q.Limit(limit)
}
if order == paging.OrderAscending {
// Page up.
q = q.OrderExpr(
"? ASC",
bun.Ident("domain_permission_exclude.id"),
)
} else {
// Page down.
q = q.OrderExpr(
"? DESC",
bun.Ident("domain_permission_exclude.id"),
)
}
if err := q.Scan(ctx, &excludeIDs); err != nil {
return nil, err
}
// Catch case of no items early
if len(excludeIDs) == 0 {
return nil, db.ErrNoEntries
}
// If we're paging up, we still want items
// to be sorted by ID desc, so reverse slice.
if order == paging.OrderAscending {
slices.Reverse(excludeIDs)
}
// Allocate return slice (will be at most len permSubIDs).
excludes := make([]*gtsmodel.DomainPermissionExclude, 0, len(excludeIDs))
for _, id := range excludeIDs {
exclude, err := d.GetDomainPermissionExcludeByID(ctx, id)
if err != nil {
log.Errorf(ctx, "error getting domain permission exclude %q: %v", id, err)
continue
}
// Append to return slice
excludes = append(excludes, exclude)
}
return excludes, nil
}
func (d *domainDB) DeleteDomainPermissionExclude(
ctx context.Context,
id string,
) error {
// Delete the permSub from DB.
q := d.db.NewDelete().
TableExpr(
"? AS ?",
bun.Ident("domain_permission_excludes"),
bun.Ident("domain_permission_exclude"),
).
Where(
"? = ?",
bun.Ident("domain_permission_exclude.id"),
id,
)
_, err := q.Exec(ctx)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
return err
}
// Clear the domain perm exclude cache (for later reload)
d.state.Caches.DB.DomainPermissionExclude.Clear()
return nil
}

View file

@ -0,0 +1,105 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package bundb_test
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
type DomainPermissionExcludeTestSuite struct {
BunDBStandardTestSuite
}
func (suite *DomainPermissionExcludeTestSuite) TestPermExcludeCreateGetDelete() {
var (
ctx = context.Background()
exclude = &gtsmodel.DomainPermissionExclude{
ID: "01JCZN614XG85GCGAMSV9ZZAEJ",
Domain: "exämple.org",
CreatedByAccountID: suite.testAccounts["admin_account"].ID,
PrivateComment: "this domain is poo",
}
)
// Whack the exclude in.
if err := suite.state.DB.PutDomainPermissionExclude(ctx, exclude); err != nil {
suite.FailNow(err.Error())
}
// Get the exclude again.
dbExclude, err := suite.state.DB.GetDomainPermissionExcludeByID(ctx, exclude.ID)
if err != nil {
suite.FailNow(err.Error())
}
// Domain should have been stored punycoded.
suite.Equal("xn--exmple-cua.org", dbExclude.Domain)
// Search for domain using both
// punycode and unicode variants.
search1, err := suite.state.DB.GetDomainPermissionExcludes(
ctx,
"exämple.org",
nil,
)
if err != nil {
suite.FailNow(err.Error())
}
if len(search1) != 1 {
suite.FailNow("couldn't get domain perm exclude exämple.org")
}
search2, err := suite.state.DB.GetDomainPermissionExcludes(
ctx,
"xn--exmple-cua.org",
nil,
)
if err != nil {
suite.FailNow(err.Error())
}
if len(search2) != 1 {
suite.FailNow("couldn't get domain perm exclude example.org")
}
// Change ID + try to put the same exclude again.
exclude.ID = "01JCZNVYSDT3JE385FABMJ7ADQ"
err = suite.state.DB.PutDomainPermissionExclude(ctx, exclude)
if !errors.Is(err, db.ErrAlreadyExists) {
suite.FailNow("was able to insert same domain perm exclude twice")
}
// Delete both excludes.
for _, id := range []string{
"01JCZN614XG85GCGAMSV9ZZAEJ",
"01JCZNVYSDT3JE385FABMJ7ADQ",
} {
if err := suite.state.DB.DeleteDomainPermissionExclude(ctx, id); err != nil {
suite.FailNow("error deleting domain permission exclude")
}
}
}
func TestDomainPermissionExcludeTestSuite(t *testing.T) {
suite.Run(t, new(DomainPermissionExcludeTestSuite))
}

View file

@ -0,0 +1,82 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package migrations
import (
"context"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/uptrace/bun"
)
func init() {
up := func(ctx context.Context, db *bun.DB) error {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
// Create `domain_permission_drafts`.
if _, err := tx.
NewCreateTable().
Model((*gtsmodel.DomainPermissionDraft)(nil)).
IfNotExists().
Exec(ctx); err != nil {
return err
}
// Create `domain_permission_ignores`.
if _, err := tx.
NewCreateTable().
Model((*gtsmodel.DomainPermissionExclude)(nil)).
IfNotExists().
Exec(ctx); err != nil {
return err
}
// Create indexes. Indices. Indie sexes.
for table, indexes := range map[string]map[string][]string{
"domain_permission_drafts": {
"domain_permission_drafts_domain_idx": {"domain"},
"domain_permission_drafts_subscription_id_idx": {"subscription_id"},
},
} {
for index, columns := range indexes {
if _, err := tx.
NewCreateIndex().
Table(table).
Index(index).
Column(columns...).
IfNotExists().
Exec(ctx); err != nil {
return err
}
}
}
return nil
})
}
down := func(ctx context.Context, db *bun.DB) error {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
return nil
})
}
if err := Migrations.Register(up, down); err != nil {
panic(err)
}
}

View file

@ -22,6 +22,7 @@
"net/url"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/paging"
)
// Domain contains DB functions related to domains and domain blocks.
@ -42,6 +43,9 @@ type Domain interface {
// GetDomainAllows returns all instance-level domain allows currently enforced by this instance.
GetDomainAllows(ctx context.Context) ([]*gtsmodel.DomainAllow, error)
// UpdateDomainAllow updates the given domain allow, setting the provided columns (empty for all).
UpdateDomainAllow(ctx context.Context, allow *gtsmodel.DomainAllow, columns ...string) error
// DeleteDomainAllow deletes an instance-level domain allow with the given domain, if it exists.
DeleteDomainAllow(ctx context.Context, domain string) error
@ -57,6 +61,9 @@ type Domain interface {
// GetDomainBlocks returns all instance-level domain blocks currently enforced by this instance.
GetDomainBlocks(ctx context.Context) ([]*gtsmodel.DomainBlock, error)
// UpdateDomainBlock updates the given domain block, setting the provided columns (empty for all).
UpdateDomainBlock(ctx context.Context, block *gtsmodel.DomainBlock, columns ...string) error
// DeleteDomainBlock deletes an instance-level domain block with the given domain, if it exists.
DeleteDomainBlock(ctx context.Context, domain string) error
@ -78,4 +85,48 @@ type Domain interface {
// AreURIsBlocked calls IsURIBlocked for each URI.
// Will return true if even one of the given URIs is blocked.
AreURIsBlocked(ctx context.Context, uris []*url.URL) (bool, error)
/*
Domain permission draft stuff.
*/
// GetDomainPermissionDraftByID gets one DomainPermissionDraft with the given ID.
GetDomainPermissionDraftByID(ctx context.Context, id string) (*gtsmodel.DomainPermissionDraft, error)
// GetDomainPermissionDrafts returns a page of
// DomainPermissionDrafts using the given parameters.
GetDomainPermissionDrafts(
ctx context.Context,
permType gtsmodel.DomainPermissionType,
permSubID string,
domain string,
page *paging.Page,
) ([]*gtsmodel.DomainPermissionDraft, error)
// PutDomainPermissionDraft stores one DomainPermissionDraft.
PutDomainPermissionDraft(ctx context.Context, permDraft *gtsmodel.DomainPermissionDraft) error
// DeleteDomainPermissionDraft deletes one DomainPermissionDraft with the given id.
DeleteDomainPermissionDraft(ctx context.Context, id string) error
/*
Domain permission exclude stuff.
*/
// GetDomainPermissionExcludeByID gets one DomainPermissionExclude with the given ID.
GetDomainPermissionExcludeByID(ctx context.Context, id string) (*gtsmodel.DomainPermissionExclude, error)
// GetDomainPermissionExcludes returns a page of
// DomainPermissionExcludes using the given parameters.
GetDomainPermissionExcludes(
ctx context.Context,
domain string,
page *paging.Page,
) ([]*gtsmodel.DomainPermissionExclude, error)
// PutDomainPermissionExclude stores one DomainPermissionExclude.
PutDomainPermissionExclude(ctx context.Context, permExclude *gtsmodel.DomainPermissionExclude) error
// DeleteDomainPermissionExclude deletes one DomainPermissionExclude with the given id.
DeleteDomainPermissionExclude(ctx context.Context, id string) error
}

View file

@ -45,6 +45,10 @@ func (d *DomainAllow) GetUpdatedAt() time.Time {
return d.UpdatedAt
}
func (d *DomainAllow) SetUpdatedAt(i time.Time) {
d.UpdatedAt = i
}
func (d *DomainAllow) GetDomain() string {
return d.Domain
}
@ -53,26 +57,50 @@ func (d *DomainAllow) GetCreatedByAccountID() string {
return d.CreatedByAccountID
}
func (d *DomainAllow) SetCreatedByAccountID(i string) {
d.CreatedByAccountID = i
}
func (d *DomainAllow) GetCreatedByAccount() *Account {
return d.CreatedByAccount
}
func (d *DomainAllow) SetCreatedByAccount(i *Account) {
d.CreatedByAccount = i
}
func (d *DomainAllow) GetPrivateComment() string {
return d.PrivateComment
}
func (d *DomainAllow) SetPrivateComment(i string) {
d.PrivateComment = i
}
func (d *DomainAllow) GetPublicComment() string {
return d.PublicComment
}
func (d *DomainAllow) SetPublicComment(i string) {
d.PublicComment = i
}
func (d *DomainAllow) GetObfuscate() *bool {
return d.Obfuscate
}
func (d *DomainAllow) SetObfuscate(i *bool) {
d.Obfuscate = i
}
func (d *DomainAllow) GetSubscriptionID() string {
return d.SubscriptionID
}
func (d *DomainAllow) SetSubscriptionID(i string) {
d.SubscriptionID = i
}
func (d *DomainAllow) GetType() DomainPermissionType {
return DomainPermissionAllow
}

View file

@ -45,6 +45,10 @@ func (d *DomainBlock) GetUpdatedAt() time.Time {
return d.UpdatedAt
}
func (d *DomainBlock) SetUpdatedAt(i time.Time) {
d.UpdatedAt = i
}
func (d *DomainBlock) GetDomain() string {
return d.Domain
}
@ -53,26 +57,50 @@ func (d *DomainBlock) GetCreatedByAccountID() string {
return d.CreatedByAccountID
}
func (d *DomainBlock) SetCreatedByAccountID(i string) {
d.CreatedByAccountID = i
}
func (d *DomainBlock) GetCreatedByAccount() *Account {
return d.CreatedByAccount
}
func (d *DomainBlock) SetCreatedByAccount(i *Account) {
d.CreatedByAccount = i
}
func (d *DomainBlock) GetPrivateComment() string {
return d.PrivateComment
}
func (d *DomainBlock) SetPrivateComment(i string) {
d.PrivateComment = i
}
func (d *DomainBlock) GetPublicComment() string {
return d.PublicComment
}
func (d *DomainBlock) SetPublicComment(i string) {
d.PublicComment = i
}
func (d *DomainBlock) GetObfuscate() *bool {
return d.Obfuscate
}
func (d *DomainBlock) SetObfuscate(i *bool) {
d.Obfuscate = i
}
func (d *DomainBlock) GetSubscriptionID() string {
return d.SubscriptionID
}
func (d *DomainBlock) SetSubscriptionID(i string) {
d.SubscriptionID = i
}
func (d *DomainBlock) GetType() DomainPermissionType {
return DomainPermissionBlock
}

View file

@ -19,19 +19,26 @@
import "time"
// DomainPermission models a domain
// permission entry (block/allow).
// DomainPermission models a domain permission
// entry -- block / allow / draft / exclude.
type DomainPermission interface {
GetID() string
GetCreatedAt() time.Time
GetUpdatedAt() time.Time
SetUpdatedAt(i time.Time)
GetDomain() string
GetCreatedByAccountID() string
SetCreatedByAccountID(i string)
GetCreatedByAccount() *Account
SetCreatedByAccount(i *Account)
GetPrivateComment() string
SetPrivateComment(i string)
GetPublicComment() string
SetPublicComment(i string)
GetObfuscate() *bool
SetObfuscate(i *bool)
GetSubscriptionID() string
SetSubscriptionID(i string)
GetType() DomainPermissionType
}

View file

@ -0,0 +1,106 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package gtsmodel
import "time"
type DomainPermissionDraft struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // ID of this item in the database.
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // Time when this item was created.
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // Time when this item was last updated.
PermissionType DomainPermissionType `bun:",notnull,unique:domain_permission_drafts_permission_type_domain_subscription_id_uniq"` // Permission type of the draft.
Domain string `bun:",nullzero,notnull,unique:domain_permission_drafts_permission_type_domain_subscription_id_uniq"` // Domain to block or allow. Eg. 'whatever.com'.
CreatedByAccountID string `bun:"type:CHAR(26),nullzero,notnull"` // Account ID of the creator of this subscription.
CreatedByAccount *Account `bun:"-"` // Account corresponding to createdByAccountID.
PrivateComment string `bun:",nullzero"` // Private comment on this perm, viewable to admins.
PublicComment string `bun:",nullzero"` // Public comment on this perm, viewable (optionally) by everyone.
Obfuscate *bool `bun:",nullzero,notnull,default:false"` // Obfuscate domain name when displaying it publicly.
SubscriptionID string `bun:"type:CHAR(26),unique:domain_permission_drafts_permission_type_domain_subscription_id_uniq"` // ID of the subscription that created this draft, if any.
}
func (d *DomainPermissionDraft) GetID() string {
return d.ID
}
func (d *DomainPermissionDraft) GetCreatedAt() time.Time {
return d.CreatedAt
}
func (d *DomainPermissionDraft) GetUpdatedAt() time.Time {
return d.UpdatedAt
}
func (d *DomainPermissionDraft) SetUpdatedAt(i time.Time) {
d.UpdatedAt = i
}
func (d *DomainPermissionDraft) GetDomain() string {
return d.Domain
}
func (d *DomainPermissionDraft) GetCreatedByAccountID() string {
return d.CreatedByAccountID
}
func (d *DomainPermissionDraft) SetCreatedByAccountID(i string) {
d.CreatedByAccountID = i
}
func (d *DomainPermissionDraft) GetCreatedByAccount() *Account {
return d.CreatedByAccount
}
func (d *DomainPermissionDraft) SetCreatedByAccount(i *Account) {
d.CreatedByAccount = i
}
func (d *DomainPermissionDraft) GetPrivateComment() string {
return d.PrivateComment
}
func (d *DomainPermissionDraft) SetPrivateComment(i string) {
d.PrivateComment = i
}
func (d *DomainPermissionDraft) GetPublicComment() string {
return d.PublicComment
}
func (d *DomainPermissionDraft) SetPublicComment(i string) {
d.PublicComment = i
}
func (d *DomainPermissionDraft) GetObfuscate() *bool {
return d.Obfuscate
}
func (d *DomainPermissionDraft) SetObfuscate(i *bool) {
d.Obfuscate = i
}
func (d *DomainPermissionDraft) GetSubscriptionID() string {
return d.SubscriptionID
}
func (d *DomainPermissionDraft) SetSubscriptionID(i string) {
d.SubscriptionID = i
}
func (d *DomainPermissionDraft) GetType() DomainPermissionType {
return d.PermissionType
}

View file

@ -0,0 +1,92 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package gtsmodel
import (
"time"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
// DomainPermissionExclude represents one domain that should be excluded
// when domain permission (excludes) are created from subscriptions.
type DomainPermissionExclude struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // ID of this item in the database.
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // Time when this item was created.
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // Time when this item was last updated.
Domain string `bun:",nullzero,notnull,unique"` // Domain to exclude. Eg. 'whatever.com'.
CreatedByAccountID string `bun:"type:CHAR(26),nullzero,notnull"` // Account ID of the creator of this exclude.
CreatedByAccount *Account `bun:"-"` // Account corresponding to createdByAccountID.
PrivateComment string `bun:",nullzero"` // Private comment on this exclude, viewable to admins.
}
func (d *DomainPermissionExclude) GetID() string {
return d.ID
}
func (d *DomainPermissionExclude) GetCreatedAt() time.Time {
return d.CreatedAt
}
func (d *DomainPermissionExclude) GetUpdatedAt() time.Time {
return d.UpdatedAt
}
func (d *DomainPermissionExclude) SetUpdatedAt(i time.Time) {
d.UpdatedAt = i
}
func (d *DomainPermissionExclude) GetDomain() string {
return d.Domain
}
func (d *DomainPermissionExclude) GetCreatedByAccountID() string {
return d.CreatedByAccountID
}
func (d *DomainPermissionExclude) SetCreatedByAccountID(i string) {
d.CreatedByAccountID = i
}
func (d *DomainPermissionExclude) GetCreatedByAccount() *Account {
return d.CreatedByAccount
}
func (d *DomainPermissionExclude) SetCreatedByAccount(i *Account) {
d.CreatedByAccount = i
}
func (d *DomainPermissionExclude) GetPrivateComment() string {
return d.PrivateComment
}
func (d *DomainPermissionExclude) SetPrivateComment(i string) {
d.PrivateComment = i
}
/*
Stubbed functions for interface purposes.
*/
func (d *DomainPermissionExclude) GetPublicComment() string { return "" }
func (d *DomainPermissionExclude) SetPublicComment(_ string) {}
func (d *DomainPermissionExclude) GetObfuscate() *bool { return util.Ptr(false) }
func (d *DomainPermissionExclude) SetObfuscate(_ *bool) {}
func (d *DomainPermissionExclude) GetSubscriptionID() string { return "" }
func (d *DomainPermissionExclude) SetSubscriptionID(_ string) {}
func (d *DomainPermissionExclude) GetType() DomainPermissionType { return DomainPermissionUnknown }

View file

@ -31,24 +31,6 @@
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
// apiDomainPerm is a cheeky shortcut for returning
// the API version of the given domain permission
// (*gtsmodel.DomainBlock or *gtsmodel.DomainAllow),
// or an appropriate error if something goes wrong.
func (p *Processor) apiDomainPerm(
ctx context.Context,
domainPermission gtsmodel.DomainPermission,
export bool,
) (*apimodel.DomainPermission, gtserror.WithCode) {
apiDomainPerm, err := p.converter.DomainPermToAPIDomainPerm(ctx, domainPermission, export)
if err != nil {
err := gtserror.NewfAt(3, "error converting domain permission to api model: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return apiDomainPerm, nil
}
// DomainPermissionCreate creates an instance-level permission
// targeting the given domain, and then processes any side
// effects of the permission creation.

View file

@ -0,0 +1,324 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package admin
import (
"context"
"errors"
"fmt"
"net/url"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
// DomainPermissionDraftGet returns one
// domain permission draft with the given id.
func (p *Processor) DomainPermissionDraftGet(
ctx context.Context,
id string,
) (*apimodel.DomainPermission, gtserror.WithCode) {
permDraft, err := p.state.DB.GetDomainPermissionDraftByID(ctx, id)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error getting domain permission draft %s: %w", id, err)
return nil, gtserror.NewErrorInternalError(err)
}
if permDraft == nil {
err := fmt.Errorf("domain permission draft %s not found", id)
return nil, gtserror.NewErrorNotFound(err, err.Error())
}
return p.apiDomainPerm(ctx, permDraft, false)
}
// DomainPermissionDraftsGet returns a page of
// DomainPermissionDrafts with the given parameters.
func (p *Processor) DomainPermissionDraftsGet(
ctx context.Context,
subscriptionID string,
domain string,
permType gtsmodel.DomainPermissionType,
page *paging.Page,
) (*apimodel.PageableResponse, gtserror.WithCode) {
permDrafts, err := p.state.DB.GetDomainPermissionDrafts(
ctx,
permType,
subscriptionID,
domain,
page,
)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
count := len(permDrafts)
if count == 0 {
return paging.EmptyResponse(), nil
}
// Get the lowest and highest
// ID values, used for paging.
lo := permDrafts[count-1].ID
hi := permDrafts[0].ID
// Convert each perm draft to API model.
items := make([]any, len(permDrafts))
for i, permDraft := range permDrafts {
apiPermDraft, err := p.apiDomainPerm(ctx, permDraft, false)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
items[i] = apiPermDraft
}
// Assemble next/prev page queries.
query := make(url.Values, 3)
if subscriptionID != "" {
query.Set(apiutil.DomainPermissionSubscriptionIDKey, subscriptionID)
}
if domain != "" {
query.Set(apiutil.DomainPermissionDomainKey, domain)
}
if permType != gtsmodel.DomainPermissionUnknown {
query.Set(apiutil.DomainPermissionPermTypeKey, permType.String())
}
return paging.PackageResponse(paging.ResponseParams{
Items: items,
Path: "/api/v1/admin/domain_permission_drafts",
Next: page.Next(lo, hi),
Prev: page.Prev(lo, hi),
Query: query,
}), nil
}
func (p *Processor) DomainPermissionDraftCreate(
ctx context.Context,
acct *gtsmodel.Account,
domain string,
permType gtsmodel.DomainPermissionType,
obfuscate bool,
publicComment string,
privateComment string,
) (*apimodel.DomainPermission, gtserror.WithCode) {
permDraft := &gtsmodel.DomainPermissionDraft{
ID: id.NewULID(),
PermissionType: permType,
Domain: domain,
CreatedByAccountID: acct.ID,
CreatedByAccount: acct,
PrivateComment: privateComment,
PublicComment: publicComment,
Obfuscate: &obfuscate,
}
if err := p.state.DB.PutDomainPermissionDraft(ctx, permDraft); err != nil {
if errors.Is(err, db.ErrAlreadyExists) {
const text = "a domain permission draft already exists with this permission type, domain, and subscription ID"
err := fmt.Errorf("%w: %s", err, text)
return nil, gtserror.NewErrorConflict(err, text)
}
// Real error.
err := gtserror.Newf("db error putting domain permission draft: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return p.apiDomainPerm(ctx, permDraft, false)
}
func (p *Processor) DomainPermissionDraftAccept(
ctx context.Context,
acct *gtsmodel.Account,
id string,
overwrite bool,
) (*apimodel.DomainPermission, string, gtserror.WithCode) {
permDraft, err := p.state.DB.GetDomainPermissionDraftByID(ctx, id)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error getting domain permission draft %s: %w", id, err)
return nil, "", gtserror.NewErrorInternalError(err)
}
if permDraft == nil {
err := fmt.Errorf("domain permission draft %s not found", id)
return nil, "", gtserror.NewErrorNotFound(err, err.Error())
}
var (
// Existing permission
// entry, if it exists.
existing gtsmodel.DomainPermission
)
// Try to get existing entry.
switch permDraft.PermissionType {
case gtsmodel.DomainPermissionBlock:
existing, err = p.state.DB.GetDomainBlock(
gtscontext.SetBarebones(ctx),
permDraft.Domain,
)
case gtsmodel.DomainPermissionAllow:
existing, err = p.state.DB.GetDomainAllow(
gtscontext.SetBarebones(ctx),
permDraft.Domain,
)
}
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error getting domain permission %s: %w", id, err)
return nil, "", gtserror.NewErrorInternalError(err)
}
// Check if we got existing entry.
existed := !util.IsNil(existing)
if existed && !overwrite {
// Domain permission exists and we shouldn't
// overwrite it, leave everything alone.
const text = "a domain permission already exists with this permission type and domain"
return nil, "", gtserror.NewErrorConflict(errors.New(text), text)
}
// Function to clean up the accepted draft, only called if
// creating or updating permission from draft is successful.
deleteDraft := func() {
if err := p.state.DB.DeleteDomainPermissionDraft(ctx, permDraft.ID); err != nil {
log.Errorf(ctx, "db error deleting domain permission draft: %v", err)
}
}
if !existed {
// Easy case, we just need to create a new domain
// permission from the draft, and then delete it.
var (
new *apimodel.DomainPermission
actionID string
errWithCode gtserror.WithCode
)
if permDraft.PermissionType == gtsmodel.DomainPermissionBlock {
new, actionID, errWithCode = p.createDomainBlock(
ctx,
acct,
permDraft.Domain,
*permDraft.Obfuscate,
permDraft.PublicComment,
permDraft.PrivateComment,
permDraft.SubscriptionID,
)
}
if permDraft.PermissionType == gtsmodel.DomainPermissionAllow {
new, actionID, errWithCode = p.createDomainAllow(
ctx,
acct,
permDraft.Domain,
*permDraft.Obfuscate,
permDraft.PublicComment,
permDraft.PrivateComment,
permDraft.SubscriptionID,
)
}
// Clean up the draft
// before returning.
deleteDraft()
return new, actionID, errWithCode
}
// Domain permission exists but we should overwrite
// it by just updating the existing domain permission.
// Domain can't change, so no need to re-run side effects.
existing.SetCreatedByAccountID(permDraft.CreatedByAccountID)
existing.SetCreatedByAccount(permDraft.CreatedByAccount)
existing.SetPrivateComment(permDraft.PrivateComment)
existing.SetPublicComment(permDraft.PublicComment)
existing.SetObfuscate(permDraft.Obfuscate)
existing.SetSubscriptionID(permDraft.SubscriptionID)
switch dp := existing.(type) {
case *gtsmodel.DomainBlock:
err = p.state.DB.UpdateDomainBlock(ctx, dp)
case *gtsmodel.DomainAllow:
err = p.state.DB.UpdateDomainAllow(ctx, dp)
}
if err != nil {
err := gtserror.Newf("db error updating existing domain permission: %w", err)
return nil, "", gtserror.NewErrorInternalError(err)
}
// Clean up the draft
// before returning.
deleteDraft()
apiPerm, errWithCode := p.apiDomainPerm(ctx, existing, false)
return apiPerm, "", errWithCode
}
func (p *Processor) DomainPermissionDraftRemove(
ctx context.Context,
acct *gtsmodel.Account,
id string,
excludeTarget bool,
) (*apimodel.DomainPermission, gtserror.WithCode) {
permDraft, err := p.state.DB.GetDomainPermissionDraftByID(ctx, id)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error getting domain permission draft %s: %w", id, err)
return nil, gtserror.NewErrorInternalError(err)
}
if permDraft == nil {
err := fmt.Errorf("domain permission draft %s not found", id)
return nil, gtserror.NewErrorNotFound(err, err.Error())
}
// Delete the permission draft.
if err := p.state.DB.DeleteDomainPermissionDraft(ctx, permDraft.ID); err != nil {
err := gtserror.Newf("db error deleting domain permission draft: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
if excludeTarget {
// Add a domain permission exclude
// targeting the permDraft's domain.
_, err = p.DomainPermissionExcludeCreate(
ctx,
acct,
permDraft.Domain,
permDraft.PrivateComment,
)
if err != nil && !errors.Is(err, db.ErrAlreadyExists) {
err := gtserror.Newf("db error creating domain permission exclude: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
}
return p.apiDomainPerm(ctx, permDraft, false)
}

View file

@ -0,0 +1,159 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package admin
import (
"context"
"errors"
"fmt"
"net/url"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/paging"
)
func (p *Processor) DomainPermissionExcludeCreate(
ctx context.Context,
acct *gtsmodel.Account,
domain string,
privateComment string,
) (*apimodel.DomainPermission, gtserror.WithCode) {
permExclude := &gtsmodel.DomainPermissionExclude{
ID: id.NewULID(),
Domain: domain,
CreatedByAccountID: acct.ID,
CreatedByAccount: acct,
PrivateComment: privateComment,
}
if err := p.state.DB.PutDomainPermissionExclude(ctx, permExclude); err != nil {
if errors.Is(err, db.ErrAlreadyExists) {
const text = "a domain permission exclude already exists with this permission type and domain"
err := fmt.Errorf("%w: %s", err, text)
return nil, gtserror.NewErrorConflict(err, text)
}
// Real error.
err := gtserror.Newf("db error putting domain permission exclude: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return p.apiDomainPerm(ctx, permExclude, false)
}
// DomainPermissionExcludeGet returns one
// domain permission exclude with the given id.
func (p *Processor) DomainPermissionExcludeGet(
ctx context.Context,
id string,
) (*apimodel.DomainPermission, gtserror.WithCode) {
permExclude, err := p.state.DB.GetDomainPermissionExcludeByID(ctx, id)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error getting domain permission exclude %s: %w", id, err)
return nil, gtserror.NewErrorInternalError(err)
}
if permExclude == nil {
err := fmt.Errorf("domain permission exclude %s not found", id)
return nil, gtserror.NewErrorNotFound(err, err.Error())
}
return p.apiDomainPerm(ctx, permExclude, false)
}
// DomainPermissionExcludesGet returns a page of
// DomainPermissionExcludes with the given parameters.
func (p *Processor) DomainPermissionExcludesGet(
ctx context.Context,
domain string,
page *paging.Page,
) (*apimodel.PageableResponse, gtserror.WithCode) {
permExcludes, err := p.state.DB.GetDomainPermissionExcludes(
ctx,
domain,
page,
)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
count := len(permExcludes)
if count == 0 {
return paging.EmptyResponse(), nil
}
// Get the lowest and highest
// ID values, used for paging.
lo := permExcludes[count-1].ID
hi := permExcludes[0].ID
// Convert each perm exclude to API model.
items := make([]any, len(permExcludes))
for i, permExclude := range permExcludes {
apiPermExclude, err := p.apiDomainPerm(ctx, permExclude, false)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
items[i] = apiPermExclude
}
// Assemble next/prev page queries.
query := make(url.Values, 1)
if domain != "" {
query.Set(apiutil.DomainPermissionDomainKey, domain)
}
return paging.PackageResponse(paging.ResponseParams{
Items: items,
Path: "/api/v1/admin/domain_permission_excludes",
Next: page.Next(lo, hi),
Prev: page.Prev(lo, hi),
Query: query,
}), nil
}
func (p *Processor) DomainPermissionExcludeRemove(
ctx context.Context,
acct *gtsmodel.Account,
id string,
) (*apimodel.DomainPermission, gtserror.WithCode) {
permExclude, err := p.state.DB.GetDomainPermissionExcludeByID(ctx, id)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error getting domain permission exclude %s: %w", id, err)
return nil, gtserror.NewErrorInternalError(err)
}
if permExclude == nil {
err := fmt.Errorf("domain permission exclude %s not found", id)
return nil, gtserror.NewErrorNotFound(err, err.Error())
}
// Delete the permission exclude.
if err := p.state.DB.DeleteDomainPermissionExclude(ctx, permExclude.ID); err != nil {
err := gtserror.Newf("db error deleting domain permission exclude: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return p.apiDomainPerm(ctx, permExclude, false)
}

View file

@ -22,6 +22,7 @@
"errors"
"time"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
@ -97,3 +98,20 @@ func (p *Processor) rangeDomainAccounts(
}
}
}
// apiDomainPerm is a cheeky shortcut for returning
// the API version of the given domain permission,
// or an appropriate error if something goes wrong.
func (p *Processor) apiDomainPerm(
ctx context.Context,
domainPermission gtsmodel.DomainPermission,
export bool,
) (*apimodel.DomainPermission, gtserror.WithCode) {
apiDomainPerm, err := p.converter.DomainPermToAPIDomainPerm(ctx, domainPermission, export)
if err != nil {
err := gtserror.NewfAt(3, "error converting domain permission to api model: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return apiDomainPerm, nil
}

View file

@ -1962,7 +1962,8 @@ func (c *Converter) ConversationToAPIConversation(
return apiConversation, nil
}
// DomainPermToAPIDomainPerm converts a gts model domin block or allow into an api domain permission.
// DomainPermToAPIDomainPerm converts a gtsmodel domain block,
// allow, draft, or ignore into an api domain permission.
func (c *Converter) DomainPermToAPIDomainPerm(
ctx context.Context,
d gtsmodel.DomainPermission,
@ -1995,6 +1996,11 @@ func (c *Converter) DomainPermToAPIDomainPerm(
domainPerm.CreatedBy = d.GetCreatedByAccountID()
domainPerm.CreatedAt = util.FormatISO8601(d.GetCreatedAt())
// If this is a draft, also add the permission type.
if _, ok := d.(*gtsmodel.DomainPermissionDraft); ok {
domainPerm.PermissionType = d.GetType().String()
}
return domainPerm, nil
}

View file

@ -17,6 +17,8 @@
package util
import "unsafe"
// EqualPtrs returns whether the values contained within two comparable ptr types are equal.
func EqualPtrs[T comparable](t1, t2 *T) bool {
switch {
@ -59,3 +61,8 @@ func PtrOrValue[T any](t *T, value T) T {
}
return value
}
func IsNil(i interface{}) bool {
type eface struct{ _, data unsafe.Pointer }
return (*eface)(unsafe.Pointer(&i)).data == nil
}

View file

@ -34,6 +34,7 @@ EXPECT=$(cat << "EOF"
"client-mem-ratio": 0.1,
"conversation-last-status-ids-mem-ratio": 2,
"conversation-mem-ratio": 1,
"domain-permission-draft-mem-ratio": 0.5,
"emoji-category-mem-ratio": 0.1,
"emoji-mem-ratio": 3,
"filter-keyword-mem-ratio": 0.5,

View file

@ -107,7 +107,11 @@ function Error({ error, reset }: ErrorProps) {
{ reset &&
<span
className="dismiss"
onClick={reset}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
reset();
}}
role="button"
tabIndex={0}
>

View file

@ -17,18 +17,107 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from "react";
import React, { useEffect } from "react";
import { useLocation } from "wouter";
import { AdminAccount } from "../lib/types/account";
import { useLazyGetAccountQuery } from "../lib/query/admin";
import Loading from "./loading";
import { Error as ErrorC } from "./error";
interface UsernameProps {
interface UsernameLozengeProps {
/**
* Either an account ID (for fetching) or an account.
*/
account?: string | AdminAccount;
/**
* Make the lozenge clickable and link to this location.
*/
linkTo?: string;
/**
* Location to set as backLocation after linking to linkTo.
*/
backLocation?: string;
/**
* Additional classnames to add to the lozenge.
*/
classNames?: string[];
}
export default function UsernameLozenge({ account, linkTo, backLocation, classNames }: UsernameLozengeProps) {
if (account === undefined) {
return <>[unknown]</>;
} else if (typeof account === "string") {
return (
<FetchUsernameLozenge
accountID={account}
linkTo={linkTo}
backLocation={backLocation}
classNames={classNames}
/>
);
} else {
return (
<ReadyUsernameLozenge
account={account}
linkTo={linkTo}
backLocation={backLocation}
classNames={classNames}
/>
);
}
}
interface FetchUsernameLozengeProps {
accountID: string;
linkTo?: string;
backLocation?: string;
classNames?: string[];
}
function FetchUsernameLozenge({ accountID, linkTo, backLocation, classNames }: FetchUsernameLozengeProps) {
const [ trigger, result ] = useLazyGetAccountQuery();
// Call to get the account
// using the provided ID.
useEffect(() => {
trigger(accountID, true);
}, [trigger, accountID]);
const {
data: account,
isLoading,
isFetching,
isError,
error,
} = result;
// Wait for the account
// model to be returned.
if (isError) {
return <ErrorC error={error} />;
} else if (isLoading || isFetching || account === undefined) {
return <Loading />;
}
return (
<ReadyUsernameLozenge
account={account}
linkTo={linkTo}
backLocation={backLocation}
classNames={classNames}
/>
);
}
interface ReadyUsernameLozengeProps {
account: AdminAccount;
linkTo?: string;
backLocation?: string;
classNames?: string[];
}
export default function Username({ account, linkTo, backLocation, classNames }: UsernameProps) {
function ReadyUsernameLozenge({ account, linkTo, backLocation, classNames }: ReadyUsernameLozengeProps) {
const [ _location, setLocation ] = useLocation();
let className = "username-lozenge";

View file

@ -110,12 +110,19 @@ export function MenuItem(props: PropsWithChildren<MenuItemProps>) {
if (topLevel) {
classNames.push("category", "top-level");
} else {
if (thisLevel === 1 && hasChildren) {
classNames.push("category", "expanding");
} else if (thisLevel === 1 && !hasChildren) {
classNames.push("view", "expanding");
} else if (thisLevel === 2) {
classNames.push("view", "nested");
switch (true) {
case thisLevel === 1 && hasChildren:
classNames.push("category", "expanding");
break;
case thisLevel === 1 && !hasChildren:
classNames.push("view", "expanding");
break;
case thisLevel >= 2 && hasChildren:
classNames.push("nested", "category");
break;
case thisLevel >= 2 && !hasChildren:
classNames.push("nested", "view");
break;
}
}

View file

@ -0,0 +1,173 @@
/*
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 {
DomainPerm,
DomainPermDraftCreateParams,
DomainPermDraftSearchParams,
DomainPermDraftSearchResp,
} from "../../../types/domain-permission";
import parse from "parse-link-header";
import { PermType } from "../../../types/perm";
const extended = gtsApi.injectEndpoints({
endpoints: (build) => ({
searchDomainPermissionDrafts: build.query<DomainPermDraftSearchResp, DomainPermDraftSearchParams>({
query: (form) => {
const params = new(URLSearchParams);
Object.entries(form).forEach(([k, v]) => {
if (v !== undefined) {
params.append(k, v);
}
});
let query = "";
if (params.size !== 0) {
query = `?${params.toString()}`;
}
return {
url: `/api/v1/admin/domain_permission_drafts${query}`
};
},
// Headers required for paging.
transformResponse: (apiResp: DomainPerm[], meta) => {
const drafts = apiResp;
const linksStr = meta?.response?.headers.get("Link");
const links = parse(linksStr);
return { drafts, links };
},
// Only provide TRANSFORMED tag id since this model is not the same
// as getDomainPermissionDraft model (due to transformResponse).
providesTags: [{ type: "DomainPermissionDraft", id: "TRANSFORMED" }]
}),
getDomainPermissionDraft: build.query<DomainPerm, string>({
query: (id) => ({
url: `/api/v1/admin/domain_permission_drafts/${id}`
}),
providesTags: (_result, _error, id) => [
{ type: 'DomainPermissionDraft', id }
],
}),
createDomainPermissionDraft: build.mutation<DomainPerm, DomainPermDraftCreateParams>({
query: (formData) => ({
method: "POST",
url: `/api/v1/admin/domain_permission_drafts`,
asForm: true,
body: formData,
discardEmpty: true
}),
invalidatesTags: [{ type: "DomainPermissionDraft", id: "TRANSFORMED" }],
}),
acceptDomainPermissionDraft: build.mutation<DomainPerm, { id: string, overwrite?: boolean, permType: PermType }>({
query: ({ id, overwrite }) => ({
method: "POST",
url: `/api/v1/admin/domain_permission_drafts/${id}/accept`,
asForm: true,
body: {
overwrite: overwrite,
},
discardEmpty: true
}),
invalidatesTags: (res, _error, { id, permType }) => {
const invalidated: any[] = [];
// If error, nothing to invalidate.
if (!res) {
return invalidated;
}
// Invalidate this draft by ID, and
// the transformed list of all drafts.
invalidated.push(
{ type: 'DomainPermissionDraft', id: id },
{ type: "DomainPermissionDraft", id: "TRANSFORMED" },
);
// Invalidate cached blocks/allows depending
// on the permType of the accepted draft.
if (permType === "allow") {
invalidated.push("domainAllows");
} else {
invalidated.push("domainBlocks");
}
return invalidated;
}
}),
removeDomainPermissionDraft: build.mutation<DomainPerm, { id: string, exclude_target?: boolean }>({
query: ({ id, exclude_target }) => ({
method: "POST",
url: `/api/v1/admin/domain_permission_drafts/${id}/remove`,
asForm: true,
body: {
exclude_target: exclude_target,
},
discardEmpty: true
}),
invalidatesTags: (res, _error, { id }) =>
res
? [
{ type: "DomainPermissionDraft", id },
{ type: "DomainPermissionDraft", id: "TRANSFORMED" },
]
: [],
})
}),
});
/**
* View domain permission drafts.
*/
const useLazySearchDomainPermissionDraftsQuery = extended.useLazySearchDomainPermissionDraftsQuery;
/**
* Get domain permission draft with the given ID.
*/
const useGetDomainPermissionDraftQuery = extended.useGetDomainPermissionDraftQuery;
/**
* Create a domain permission draft with the given parameters.
*/
const useCreateDomainPermissionDraftMutation = extended.useCreateDomainPermissionDraftMutation;
/**
* Accept a domain permission draft, turning it into an enforced domain permission.
*/
const useAcceptDomainPermissionDraftMutation = extended.useAcceptDomainPermissionDraftMutation;
/**
* Remove a domain permission draft, optionally ignoring all future drafts targeting the given domain.
*/
const useRemoveDomainPermissionDraftMutation = extended.useRemoveDomainPermissionDraftMutation;
export {
useLazySearchDomainPermissionDraftsQuery,
useGetDomainPermissionDraftQuery,
useCreateDomainPermissionDraftMutation,
useAcceptDomainPermissionDraftMutation,
useRemoveDomainPermissionDraftMutation,
};

View file

@ -0,0 +1,124 @@
/*
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 {
DomainPerm,
DomainPermExcludeCreateParams,
DomainPermExcludeSearchParams,
DomainPermExcludeSearchResp,
} from "../../../types/domain-permission";
import parse from "parse-link-header";
const extended = gtsApi.injectEndpoints({
endpoints: (build) => ({
searchDomainPermissionExcludes: build.query<DomainPermExcludeSearchResp, DomainPermExcludeSearchParams>({
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_excludes${query}`
};
},
// Headers required for paging.
transformResponse: (apiResp: DomainPerm[], meta) => {
const excludes = apiResp;
const linksStr = meta?.response?.headers.get("Link");
const links = parse(linksStr);
return { excludes, links };
},
// Only provide TRANSFORMED tag id since this model is not the same
// as getDomainPermissionExclude model (due to transformResponse).
providesTags: [{ type: "DomainPermissionExclude", id: "TRANSFORMED" }]
}),
getDomainPermissionExclude: build.query<DomainPerm, string>({
query: (id) => ({
url: `/api/v1/admin/domain_permission_excludes/${id}`
}),
providesTags: (_result, _error, id) => [
{ type: 'DomainPermissionExclude', id }
],
}),
createDomainPermissionExclude: build.mutation<DomainPerm, DomainPermExcludeCreateParams>({
query: (formData) => ({
method: "POST",
url: `/api/v1/admin/domain_permission_excludes`,
asForm: true,
body: formData,
discardEmpty: true
}),
invalidatesTags: [{ type: "DomainPermissionExclude", id: "TRANSFORMED" }],
}),
deleteDomainPermissionExclude: build.mutation<DomainPerm, string>({
query: (id) => ({
method: "DELETE",
url: `/api/v1/admin/domain_permission_excludes/${id}`,
}),
invalidatesTags: (res, _error, id) =>
res
? [
{ type: "DomainPermissionExclude", id },
{ type: "DomainPermissionExclude", id: "TRANSFORMED" },
]
: [],
})
}),
});
/**
* View domain permission excludes.
*/
const useLazySearchDomainPermissionExcludesQuery = extended.useLazySearchDomainPermissionExcludesQuery;
/**
* Get domain permission exclude with the given ID.
*/
const useGetDomainPermissionExcludeQuery = extended.useGetDomainPermissionExcludeQuery;
/**
* Create a domain permission exclude with the given parameters.
*/
const useCreateDomainPermissionExcludeMutation = extended.useCreateDomainPermissionExcludeMutation;
/**
* Delete a domain permission exclude.
*/
const useDeleteDomainPermissionExcludeMutation = extended.useDeleteDomainPermissionExcludeMutation;
export {
useLazySearchDomainPermissionExcludesQuery,
useGetDomainPermissionExcludeQuery,
useCreateDomainPermissionExcludeMutation,
useDeleteDomainPermissionExcludeMutation,
};

View file

@ -37,6 +37,12 @@ const extended = gtsApi.injectEndpoints({
}),
transformResponse: listToKeyedObject<DomainPerm>("domain"),
}),
domainPermissionDrafts: build.query<any, void>({
query: () => ({
url: `/api/v1/admin/domain_permission_drafts`
}),
}),
}),
});

View file

@ -24,7 +24,7 @@ import {
type DomainPerm,
type ImportDomainPermsParams,
type MappedDomainPerms,
isDomainPermInternalKey,
stripOnImport,
} from "../../../types/domain-permission";
import { listToKeyedObject } from "../../transforms";
@ -83,7 +83,7 @@ function importEntriesProcessor(formData: ImportDomainPermsParams): (_entry: Dom
// Unset all internal processing keys
// and any undefined keys on this entry.
Object.entries(entry).forEach(([key, val]: [keyof DomainPerm, any]) => {
if (val == undefined || isDomainPermInternalKey(key)) {
if (val == undefined || stripOnImport(key)) {
delete entry[key];
}
});

View file

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

View file

@ -19,11 +19,12 @@
import typia from "typia";
import { PermType } from "./perm";
import { Links } from "parse-link-header";
export const validateDomainPerms = typia.createValidate<DomainPerm[]>();
/**
* A single domain permission entry (block or allow).
* A single domain permission entry (block, allow, draft, ignore).
*/
export interface DomainPerm {
id?: string;
@ -32,11 +33,14 @@ export interface DomainPerm {
private_comment?: string;
public_comment?: string;
created_at?: string;
created_by?: string;
subscription_id?: string;
// Internal processing keys; remove
// before serdes of domain perm.
// Keys that should be stripped before
// sending the domain permission (if imported).
permission_type?: PermType;
key?: string;
permType?: PermType;
suggest?: string;
valid?: boolean;
checked?: boolean;
@ -53,9 +57,9 @@ export interface MappedDomainPerms {
[key: string]: DomainPerm;
}
const domainPermInternalKeys: Set<keyof DomainPerm> = new Set([
const domainPermStripOnImport: Set<keyof DomainPerm> = new Set([
"key",
"permType",
"permission_type",
"suggest",
"valid",
"checked",
@ -65,15 +69,14 @@ const domainPermInternalKeys: Set<keyof DomainPerm> = new Set([
]);
/**
* Returns true if provided DomainPerm Object key is
* "internal"; ie., it's just for our use, and it shouldn't
* be serialized to or deserialized from the GtS API.
* Returns true if provided DomainPerm Object key is one
* that should be stripped when importing a domain permission.
*
* @param key
* @returns
*/
export function isDomainPermInternalKey(key: keyof DomainPerm) {
return domainPermInternalKeys.has(key);
export function stripOnImport(key: keyof DomainPerm) {
return domainPermStripOnImport.has(key);
}
export interface ImportDomainPermsParams {
@ -94,3 +97,119 @@ export interface ExportDomainPermsParams {
action: "export" | "export-file";
exportType: "json" | "csv" | "plain";
}
/**
* Parameters for GET to /api/v1/admin/domain_permission_drafts.
*/
export interface DomainPermDraftSearchParams {
/**
* Show only drafts created by the given subscription ID.
*/
subscription_id?: string;
/**
* Return only drafts that target the given domain.
*/
domain?: string;
/**
* Filter on "block" or "allow" type drafts.
*/
permission_type?: PermType;
/**
* Return only items *OLDER* than the given max ID (for paging downwards).
* The item with the specified ID will not be included in the response.
*/
max_id?: string;
/**
* Return only items *NEWER* than the given since ID.
* The item with the specified ID will not be included in the response.
*/
since_id?: string;
/**
* Return only items immediately *NEWER* than the given min ID (for paging upwards).
* The item with the specified ID will not be included in the response.
*/
min_id?: string;
/**
* Number of items to return.
*/
limit?: number;
}
export interface DomainPermDraftSearchResp {
drafts: DomainPerm[];
links: Links | null;
}
export interface DomainPermDraftCreateParams {
/**
* Domain to create the permission draft for.
*/
domain: string;
/**
* Create a draft "allow" or a draft "block".
*/
permission_type: PermType;
/**
* Obfuscate the name of the domain when serving it publicly.
* Eg., `example.org` becomes something like `ex***e.org`.
*/
obfuscate?: boolean;
/**
* Public comment about this domain permission. This will be displayed
* alongside the domain permission if you choose to share permissions.
*/
public_comment?: string;
/**
* Private comment about this domain permission.
* Will only be shown to other admins, so this is a useful way of
* internally keeping track of why a certain domain ended up permissioned.
*/
private_comment?: string;
}
/**
* Parameters for GET to /api/v1/admin/domain_permission_excludes.
*/
export interface DomainPermExcludeSearchParams {
/**
* Return only excludes that target the given domain.
*/
domain?: string;
/**
* 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 DomainPermExcludeSearchResp {
excludes: DomainPerm[];
links: Links | null;
}
export interface DomainPermExcludeCreateParams {
/**
* Domain to create the permission exclude for.
*/
domain: string;
/**
* Private comment about this domain permission.
* Will only be shown to other admins, so this is a useful way of
* internally keeping track of why a certain domain ended up permissioned.
*/
private_comment?: string;
}

View file

@ -0,0 +1,48 @@
/*
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 isValidDomain from "is-valid-domain";
/**
* Validate the "domain" field of a form.
* @param domain
* @returns
*/
export function formDomainValidator(domain: string): string {
if (domain.length === 0) {
return "";
}
if (domain[domain.length-1] === ".") {
return "invalid domain";
}
const valid = isValidDomain(domain, {
subdomain: true,
wildcard: false,
allowUnicode: true,
topLevel: false,
});
if (valid) {
return "";
}
return "invalid domain";
}

View file

@ -41,3 +41,16 @@ export function UseOurInstanceAccount(account: AdminAccount): boolean {
return !account.domain && account.username == ourDomain;
}
/**
* Uppercase first letter of given string.
*/
export function useCapitalize(i?: string): string {
return useMemo(() => {
if (i === undefined) {
return "";
}
return i.charAt(0).toUpperCase() + i.slice(1);
}, [i]);
}

View file

@ -194,7 +194,8 @@ nav.menu-tree {
}
}
li.nested { /* any deeper nesting, just has indent */
/* Deeper nesting. */
li.nested {
a.title {
padding-left: 1rem;
font-weight: normal;
@ -210,11 +211,35 @@ nav.menu-tree {
background: $settings-nav-bg-hover;
}
}
&.active > a.title {
color: $fg-accent;
font-weight: bold;
}
&.active {
a.title {
color: $fg-accent;
font-weight: bold;
&.category {
& > a.title {
&::after {
content: "▶";
left: 0.8rem;
bottom: 0.1rem;
position: relative;
}
}
&.active {
& > a.title {
&::after {
content: "▼";
bottom: 0;
}
border-bottom: 0.15rem dotted $gray1;
}
}
li.nested > a.title {
padding-left: 2rem;
}
}
}
@ -1334,6 +1359,66 @@ button.tab-button {
}
}
.domain-permission-drafts-view,
.domain-permission-excludes-view {
.domain-permission-draft,
.domain-permission-exclude {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
gap: 0.5rem;
&.block {
border-left: 0.3rem solid $error3;
}
&.allow {
border-left: 0.3rem solid $green1;
}
&:hover {
border-color: $fg-accent;
}
.info-list {
border: none;
.info-list-entry {
background: none;
padding: 0;
}
}
.action-buttons {
display: flex;
gap: 0.5rem;
align-items: center;
> .mutation-button
> button {
font-size: 1rem;
line-height: 1rem;
}
}
}
}
.domain-permission-draft-details,
.domain-permission-exclude-details {
.info-list {
margin-top: 1rem;
}
}
.domain-permission-drafts-view,
.domain-permission-draft-details {
dd.permission-type {
display: flex;
gap: 0.35rem;
align-items: center;
}
}
.instance-rules {
list-style-position: inside;
margin: 0;

View file

@ -22,32 +22,11 @@ import { TextInput } from "../../../../components/form/inputs";
import MutationButton from "../../../../components/form/mutation-button";
import { useTextInput } from "../../../../lib/form";
import { useInstanceKeysExpireMutation } from "../../../../lib/query/admin/actions";
import isValidDomain from "is-valid-domain";
import { formDomainValidator } from "../../../../lib/util/formvalidators";
export default function ExpireRemote({}) {
const domainField = useTextInput("domain", {
validator: (v: string) => {
if (v.length === 0) {
return "";
}
if (v[v.length-1] === ".") {
return "invalid domain";
}
const valid = isValidDomain(v, {
subdomain: true,
wildcard: false,
allowUnicode: true,
topLevel: false,
});
if (valid) {
return "";
}
return "invalid domain";
}
validator: formDomainValidator,
});
const [expire, expireResult] = useInstanceKeysExpireMutation();

View file

@ -17,7 +17,7 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { useEffect, useMemo } from "react";
import React, { useMemo } from "react";
import { useLocation, useParams } from "wouter";
import { PermType } from "../../../lib/types/perm";
import { useDeleteHeaderAllowMutation, useDeleteHeaderBlockMutation, useGetHeaderAllowQuery, useGetHeaderBlockQuery } from "../../../lib/query/admin/http-header-permissions";
@ -26,8 +26,7 @@ import { FetchBaseQueryError } from "@reduxjs/toolkit/query";
import { SerializedError } from "@reduxjs/toolkit";
import Loading from "../../../components/loading";
import { Error } from "../../../components/error";
import { useLazyGetAccountQuery } from "../../../lib/query/admin";
import Username from "../../../components/username";
import UsernameLozenge from "../../../components/username-lozenge";
import { useBaseUrl } from "../../../lib/navigation/util";
import BackButton from "../../../components/back-button";
import MutationButton from "../../../components/form/mutation-button";
@ -92,58 +91,19 @@ interface PermDeetsProps {
function PermDeets({
permType,
data: perm,
isLoading: isLoadingPerm,
isFetching: isFetchingPerm,
isError: isErrorPerm,
error: errorPerm,
isLoading,
isFetching,
isError,
error,
}: PermDeetsProps) {
const [ location ] = useLocation();
const baseUrl = useBaseUrl();
// Once we've loaded the perm, trigger
// getting the account that created it.
const [ getAccount, getAccountRes ] = useLazyGetAccountQuery();
useEffect(() => {
if (!perm) {
return;
}
getAccount(perm.created_by, true);
}, [getAccount, perm]);
// Load the createdByAccount if possible,
// returning a username lozenge with
// a link to the account.
const createdByAccount = useMemo(() => {
const {
data: account,
isLoading: isLoadingAccount,
isFetching: isFetchingAccount,
isError: isErrorAccount,
} = getAccountRes;
// Wait for query to finish, returning
// loading spinner in the meantime.
if (isLoadingAccount || isFetchingAccount || !perm) {
return <Loading />;
} else if (isErrorAccount || account === undefined) {
// Fall back to account ID.
return perm?.created_by;
}
return (
<Username
account={account}
linkTo={`~/settings/moderation/accounts/${account.id}`}
backLocation={`~${baseUrl}${location}`}
/>
);
}, [getAccountRes, perm, baseUrl, location]);
// Now wait til the perm itself is loaded.
if (isLoadingPerm || isFetchingPerm) {
// Wait til the perm itself is loaded.
if (isLoading || isFetching) {
return <Loading />;
} else if (isErrorPerm) {
return <Error error={errorPerm} />;
} else if (isError) {
return <Error error={error} />;
} else if (perm === undefined) {
throw "perm undefined";
}
@ -172,7 +132,13 @@ function PermDeets({
</div>
<div className="info-list-entry">
<dt>Created By</dt>
<dd>{createdByAccount}</dd>
<dd>
<UsernameLozenge
account={perm.created_by}
linkTo={`~/settings/moderation/accounts/${perm.created_by}`}
backLocation={`~${baseUrl}${location}`}
/>
</dd>
</div>
<div className="info-list-entry">
<dt>Header Name</dt>

View file

@ -27,6 +27,7 @@ import { PermType } from "../../../lib/types/perm";
import { FetchBaseQueryError } from "@reduxjs/toolkit/query";
import { SerializedError } from "@reduxjs/toolkit";
import HeaderPermCreateForm from "./create";
import { useCapitalize } from "../../../lib/util";
export default function HeaderPermsOverview() {
const [ location, setLocation ] = useLocation();
@ -41,9 +42,7 @@ export default function HeaderPermsOverview() {
}, [params]);
// Uppercase first letter of given permType.
const permTypeUpper = useMemo(() => {
return permType.charAt(0).toUpperCase() + permType.slice(1);
}, [permType]);
const permTypeUpper = useCapitalize(permType);
// Fetch desired perms, skipping
// the ones we don't want.

View file

@ -21,7 +21,7 @@ import React, { ReactNode } from "react";
import { useSearchAccountsQuery } from "../../../../lib/query/admin";
import { PageableList } from "../../../../components/pageable-list";
import { useLocation } from "wouter";
import Username from "../../../../components/username";
import UsernameLozenge from "../../../../components/username-lozenge";
import { AdminAccount } from "../../../../lib/types/account";
export default function AccountsPending() {
@ -32,7 +32,7 @@ export default function AccountsPending() {
function itemToEntry(account: AdminAccount): ReactNode {
const acc = account.account;
return (
<Username
<UsernameLozenge
key={acc.acct}
account={account}
linkTo={`/${account.id}`}

View file

@ -26,8 +26,8 @@ import { Select, TextInput } from "../../../../components/form/inputs";
import MutationButton from "../../../../components/form/mutation-button";
import { useLocation, useSearch } from "wouter";
import { AdminAccount } from "../../../../lib/types/account";
import Username from "../../../../components/username";
import isValidDomain from "is-valid-domain";
import UsernameLozenge from "../../../../components/username-lozenge";
import { formDomainValidator } from "../../../../lib/util/formvalidators";
export function AccountSearchForm() {
const [ location, setLocation ] = useLocation();
@ -45,28 +45,7 @@ export function AccountSearchForm() {
display_name: useTextInput("display_name", { defaultValue: urlQueryParams.get("display_name") ?? ""}),
by_domain: useTextInput("by_domain", {
defaultValue: urlQueryParams.get("by_domain") ?? "",
validator: (v: string) => {
if (v.length === 0) {
return "";
}
if (v[v.length-1] === ".") {
return "invalid domain";
}
const valid = isValidDomain(v, {
subdomain: true,
wildcard: false,
allowUnicode: true,
topLevel: false,
});
if (valid) {
return "";
}
return "invalid domain";
}
validator: formDomainValidator,
}),
email: useTextInput("email", { defaultValue: urlQueryParams.get("email") ?? ""}),
ip: useTextInput("ip", { defaultValue: urlQueryParams.get("ip") ?? ""}),
@ -114,7 +93,7 @@ export function AccountSearchForm() {
function itemToEntry(account: AdminAccount): ReactNode {
const acc = account.account;
return (
<Username
<UsernameLozenge
key={acc.acct}
account={account}
linkTo={`/${account.id}`}

View file

@ -39,37 +39,47 @@ import { NoArg } from "../../../lib/types/query";
import { Error } from "../../../components/error";
import { useBaseUrl } from "../../../lib/navigation/util";
import { PermType } from "../../../lib/types/perm";
import isValidDomain from "is-valid-domain";
import { useCapitalize } from "../../../lib/util";
import { formDomainValidator } from "../../../lib/util/formvalidators";
export default function DomainPermDetail() {
const baseUrl = useBaseUrl();
// Parse perm type from routing params.
let params = useParams();
if (params.permType !== "blocks" && params.permType !== "allows") {
const search = useSearch();
// Parse perm type from routing params, converting
// "blocks" => "block" and "allows" => "allow".
const params = useParams();
const permTypeRaw = params.permType;
if (permTypeRaw !== "blocks" && permTypeRaw !== "allows") {
throw "unrecognized perm type " + params.permType;
}
const permType = params.permType.slice(0, -1) as PermType;
const permType = useMemo(() => {
return permTypeRaw.slice(0, -1) as PermType;
}, [permTypeRaw]);
const { data: domainBlocks = {}, isLoading: isLoadingDomainBlocks } = useDomainBlocksQuery(NoArg, { skip: permType !== "block" });
const { data: domainAllows = {}, isLoading: isLoadingDomainAllows } = useDomainAllowsQuery(NoArg, { skip: permType !== "allow" });
// Conditionally fetch either domain blocks or domain
// allows depending on which perm type we're looking at.
const {
data: blocks = {},
isLoading: loadingBlocks,
isFetching: fetchingBlocks,
} = useDomainBlocksQuery(NoArg, { skip: permType !== "block" });
const {
data: allows = {},
isLoading: loadingAllows,
isFetching: fetchingAllows,
} = useDomainAllowsQuery(NoArg, { skip: permType !== "allow" });
let isLoading;
switch (permType) {
case "block":
isLoading = isLoadingDomainBlocks;
break;
case "allow":
isLoading = isLoadingDomainAllows;
break;
default:
throw "perm type unknown";
// Wait until we're done loading.
const loading = permType === "block"
? loadingBlocks || fetchingBlocks
: loadingAllows || fetchingAllows;
if (loading) {
return <Loading />;
}
// Parse domain from routing params.
let domain = params.domain ?? "unknown";
const search = useSearch();
if (domain === "view") {
// Retrieve domain from form field submission.
const searchParams = new URLSearchParams(search);
@ -81,36 +91,41 @@ export default function DomainPermDetail() {
domain = searchDomain;
}
// Normalize / decode domain (it may be URL-encoded).
// Normalize / decode domain
// (it may be URL-encoded).
domain = decodeURIComponent(domain);
// Check if we already have a perm of the desired type for this domain.
const existingPerm: DomainPerm | undefined = useMemo(() => {
if (permType == "block") {
return domainBlocks[domain];
} else {
return domainAllows[domain];
}
}, [domainBlocks, domainAllows, domain, permType]);
// Check if we already have a perm
// of the desired type for this domain.
const existingPerm = permType === "block"
? blocks[domain]
: allows[domain];
// Render different into content depending on
// if we have a perm already for this domain.
let infoContent: React.JSX.Element;
if (isLoading) {
infoContent = <Loading />;
} else if (existingPerm == undefined) {
infoContent = <span>No stored {permType} yet, you can add one below:</span>;
if (existingPerm === undefined) {
infoContent = (
<span>
No stored {permType} yet, you can add one below:
</span>
);
} else {
infoContent = (
<div className="info">
<i className="fa fa-fw fa-exclamation-triangle" aria-hidden="true"></i>
<b>Editing domain permissions isn't implemented yet, <a href="https://github.com/superseriousbusiness/gotosocial/issues/1198" target="_blank" rel="noopener noreferrer">check here for progress</a></b>
<b>Editing existing domain {permTypeRaw} isn't implemented yet, <a href="https://github.com/superseriousbusiness/gotosocial/issues/1198" target="_blank" rel="noopener noreferrer">check here for progress</a></b>
</div>
);
}
return (
<div>
<h1 className="text-cutoff"><BackButton to={`~${baseUrl}/${permType}s`}/> Domain {permType} for: <span title={domain}>{domain}</span></h1>
<h1 className="text-cutoff">
<BackButton to={`~${baseUrl}/${permTypeRaw}`} />
{" "}
Domain {permType} for {domain}
</h1>
{infoContent}
<DomainPermForm
defaultDomain={domain}
@ -143,28 +158,7 @@ function DomainPermForm({ defaultDomain, perm, permType }: DomainPermFormProps)
domain: useTextInput("domain", {
source: perm,
defaultValue: defaultDomain,
validator: (v: string) => {
if (v.length === 0) {
return "";
}
if (v[v.length-1] === ".") {
return "invalid domain";
}
const valid = isValidDomain(v, {
subdomain: true,
wildcard: false,
allowUnicode: true,
topLevel: false,
});
if (valid) {
return "";
}
return "invalid domain";
}
validator: formDomainValidator,
}),
obfuscate: useBoolInput("obfuscate", { source: perm }),
commentPrivate: useTextInput("private_comment", { source: perm }),
@ -209,9 +203,7 @@ function DomainPermForm({ defaultDomain, perm, permType }: DomainPermFormProps)
const [submitForm, submitFormResult] = useFormSubmit(form, [addTrigger, addResult], { changedOnly: false });
// Uppercase first letter of given permType.
const permTypeUpper = useMemo(() => {
return permType.charAt(0).toUpperCase() + permType.slice(1);
}, [permType]);
const permTypeUpper = useCapitalize(permType);
const [location, setLocation] = useLocation();

View file

@ -0,0 +1,43 @@
/*
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 from "react";
export function DomainPermissionDraftHelpText() {
return (
<>
Domain permission drafts are domain block or domain allow entries that are not yet in force.
<br/>
You can choose to accept or remove a draft.
</>
);
}
export function DomainPermissionDraftDocsLink() {
return (
<a
href="https://docs.gotosocial.org/en/latest/admin/settings/#domain-permission-drafts"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about domain permission drafts (opens in a new tab)
</a>
);
}

View file

@ -0,0 +1,210 @@
/*
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 from "react";
import { useLocation, useParams } from "wouter";
import Loading from "../../../../components/loading";
import { useBaseUrl } from "../../../../lib/navigation/util";
import BackButton from "../../../../components/back-button";
import {
useAcceptDomainPermissionDraftMutation,
useGetDomainPermissionDraftQuery,
useRemoveDomainPermissionDraftMutation
} from "../../../../lib/query/admin/domain-permissions/drafts";
import { Error as ErrorC } from "../../../../components/error";
import UsernameLozenge from "../../../../components/username-lozenge";
import MutationButton from "../../../../components/form/mutation-button";
import { useBoolInput, useTextInput } from "../../../../lib/form";
import { Checkbox, Select } from "../../../../components/form/inputs";
import { PermType } from "../../../../lib/types/perm";
export default function DomainPermissionDraftDetail() {
const baseUrl = useBaseUrl();
const backLocation: string = history.state?.backLocation ?? `~${baseUrl}`;
const params = useParams();
let id = params.permDraftId as string | undefined;
if (!id) {
throw "no perm ID";
}
const {
data: permDraft,
isLoading,
isFetching,
isError,
error,
} = useGetDomainPermissionDraftQuery(id);
if (isLoading || isFetching) {
return <Loading />;
} else if (isError) {
return <ErrorC error={error} />;
} else if (permDraft === undefined) {
return <ErrorC error={new Error("permission draft was undefined")} />;
}
const created = permDraft.created_at ? new Date(permDraft.created_at).toDateString(): "unknown";
const domain = permDraft.domain;
const permType = permDraft.permission_type;
if (!permType) {
return <ErrorC error={new Error("permission_type was undefined")} />;
}
const publicComment = permDraft.public_comment ?? "[none]";
const privateComment = permDraft.private_comment ?? "[none]";
const subscriptionID = permDraft.subscription_id ?? "[none]";
return (
<div className="domain-permission-draft-details">
<h1><BackButton to={backLocation} /> Domain Permission Draft Detail</h1>
<dl className="info-list">
<div className="info-list-entry">
<dt>Created</dt>
<dd><time dateTime={permDraft.created_at}>{created}</time></dd>
</div>
<div className="info-list-entry">
<dt>Created By</dt>
<dd>
<UsernameLozenge
account={permDraft.created_by}
linkTo={`~/settings/moderation/accounts/${permDraft.created_by}`}
backLocation={`~${location}`}
/>
</dd>
</div>
<div className="info-list-entry">
<dt>Domain</dt>
<dd>{domain}</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>Private comment</dt>
<dd>{privateComment}</dd>
</div>
<div className="info-list-entry">
<dt>Public comment</dt>
<dd>{publicComment}</dd>
</div>
<div className="info-list-entry">
<dt>Subscription ID</dt>
<dd>{subscriptionID}</dd>
</div>
</dl>
<HandleDraft
id={id}
permType={permType}
backLocation={backLocation}
/>
</div>
);
}
function HandleDraft({ id, permType, backLocation }: { id: string, permType: PermType, backLocation: string }) {
const [ accept, acceptResult ] = useAcceptDomainPermissionDraftMutation();
const [ remove, removeResult ] = useRemoveDomainPermissionDraftMutation();
const [_location, setLocation] = useLocation();
const form = {
acceptOrRemove: useTextInput("accept_or_remove", { defaultValue: "accept" }),
overwrite: useBoolInput("overwrite"),
exclude_target: useBoolInput("exclude_target"),
};
const onClick = (e) => {
e.preventDefault();
if (form.acceptOrRemove.value === "accept") {
const overwrite = form.overwrite.value;
accept({id, overwrite, permType}).then(res => {
if ("data" in res) {
setLocation(backLocation);
}
});
} else {
const exclude_target = form.exclude_target.value;
remove({id, exclude_target}).then(res => {
if ("data" in res) {
setLocation(backLocation);
}
});
}
};
return (
<form>
<Select
field={form.acceptOrRemove}
label="Accept or remove draft"
options={
<>
<option value="accept">Accept</option>
<option value="remove">Remove</option>
</>
}
></Select>
{ form.acceptOrRemove.value === "accept" &&
<>
<Checkbox
field={form.overwrite}
label={`Overwrite any existing ${permType} for this domain`}
/>
</>
}
{ form.acceptOrRemove.value === "remove" &&
<>
<Checkbox
field={form.exclude_target}
label={`Add a domain permission exclude for this domain`}
/>
</>
}
<MutationButton
label={
form.acceptOrRemove.value === "accept"
? `Accept ${permType}`
: "Remove draft"
}
type="button"
className={
form.acceptOrRemove.value === "accept"
? "button"
: "button danger"
}
onClick={onClick}
disabled={false}
showError={true}
result={
form.acceptOrRemove.value === "accept"
? acceptResult
: removeResult
}
/>
</form>
);
}

View file

@ -0,0 +1,293 @@
/*
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 { useAcceptDomainPermissionDraftMutation, useLazySearchDomainPermissionDraftsQuery, useRemoveDomainPermissionDraftMutation } from "../../../../lib/query/admin/domain-permissions/drafts";
import { DomainPerm } from "../../../../lib/types/domain-permission";
import { Error as ErrorC } from "../../../../components/error";
import { Select, TextInput } from "../../../../components/form/inputs";
import { formDomainValidator } from "../../../../lib/util/formvalidators";
import { useCapitalize } from "../../../../lib/util";
import { DomainPermissionDraftDocsLink, DomainPermissionDraftHelpText } from "./common";
export default function DomainPermissionDraftsSearch() {
return (
<div className="domain-permission-drafts-view">
<div className="form-section-docs">
<h1>Domain Permission Drafts</h1>
<p>
You can use the form below to search through domain permission drafts.
<br/>
<DomainPermissionDraftHelpText />
</p>
<DomainPermissionDraftDocsLink />
</div>
<DomainPermissionDraftsSearchForm />
</div>
);
}
function DomainPermissionDraftsSearchForm() {
const [ location, setLocation ] = useLocation();
const search = useSearch();
const urlQueryParams = useMemo(() => new URLSearchParams(search), [search]);
const hasParams = urlQueryParams.size != 0;
const [ searchDrafts, searchRes ] = useLazySearchDomainPermissionDraftsQuery();
const form = {
subscription_id: useTextInput("subscription_id", { defaultValue: urlQueryParams.get("subscription_id") ?? "" }),
domain: useTextInput("domain", {
defaultValue: urlQueryParams.get("domain") ?? "",
validator: formDomainValidator,
}),
permission_type: useTextInput("permission_type", { defaultValue: urlQueryParams.get("permission_type") ?? "" }),
limit: useTextInput("limit", { defaultValue: urlQueryParams.get("limit") ?? "20" })
};
// On mount, if urlQueryParams were provided,
// trigger the search. For example, if page
// was accessed at /search?origin=local&limit=20,
// then run a search with origin=local and
// limit=20 and immediately render the results.
//
// If no urlQueryParams set, trigger default
// search (first page, no filtering).
useEffect(() => {
if (hasParams) {
searchDrafts(Object.fromEntries(urlQueryParams));
} else {
setLocation(location + "?limit=20");
}
}, [
urlQueryParams,
hasParams,
searchDrafts,
location,
setLocation,
]);
// Rather than triggering the search directly,
// the "submit" button changes the location
// based on form field params, and lets the
// useEffect hook above actually do the search.
function submitQuery(e) {
e.preventDefault();
// Parse query parameters.
const entries = Object.entries(form).map(([k, v]) => {
// Take only defined form fields.
if (v.value === undefined || v.value.length === 0 || v.value === "any") {
return null;
}
return [[k, v.value]];
}).flatMap(kv => {
// Remove any nulls.
return kv || [];
});
const searchParams = new URLSearchParams(entries);
setLocation(location + "?" + searchParams.toString());
}
// Location to return to when user clicks "back" on the detail view.
const backLocation = location + (hasParams ? `?${urlQueryParams}` : "");
// Function to map an item to a list entry.
function itemToEntry(draft: DomainPerm): ReactNode {
return (
<DraftListEntry
key={draft.id}
permDraft={draft}
linkTo={`/drafts/${draft.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>
<TextInput
field={form.domain}
label={`Domain (without "https://" prefix)`}
placeholder="example.org"
autoCapitalize="none"
spellCheck="false"
/>
<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?.drafts}
itemToEntry={itemToEntry}
isError={searchRes.isError}
error={searchRes.error}
emptyMessage={<b>No drafts found that match your query.</b>}
prevNextLinks={searchRes.data?.links}
/>
</>
);
}
interface DraftEntryProps {
permDraft: DomainPerm;
linkTo: string;
backLocation: string;
}
function DraftListEntry({ permDraft, linkTo, backLocation }: DraftEntryProps) {
const [ _location, setLocation ] = useLocation();
const [ accept, acceptResult ] = useAcceptDomainPermissionDraftMutation();
const [ remove, removeResult ] = useRemoveDomainPermissionDraftMutation();
const domain = permDraft.domain;
const permType = permDraft.permission_type;
const permTypeUpper = useCapitalize(permType);
if (!permType) {
return <ErrorC error={new Error("permission_type was undefined")} />;
}
const publicComment = permDraft.public_comment ?? "[none]";
const privateComment = permDraft.private_comment ?? "[none]";
const subscriptionID = permDraft.subscription_id ?? "[none]";
const id = permDraft.id;
if (!id) {
return <ErrorC error={new Error("id was undefined")} />;
}
const title = `${permTypeUpper} ${domain}`;
return (
<span
className={`pseudolink domain-permission-draft entry ${permType}`}
aria-label={title}
title={title}
onClick={() => {
// When clicking on a draft, direct
// to the detail view for that draft.
setLocation(linkTo, {
// Store the back location in history so
// the detail view can use it to return to
// this page (including query parameters).
state: { backLocation: backLocation }
});
}}
role="link"
tabIndex={0}
>
<h3>{title}</h3>
<dl className="info-list">
<div className="info-list-entry">
<dt>Domain:</dt>
<dd className="text-cutoff">{domain}</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>Private comment:</dt>
<dd className="text-cutoff">{privateComment}</dd>
</div>
<div className="info-list-entry">
<dt>Public comment:</dt>
<dd>{publicComment}</dd>
</div>
<div className="info-list-entry">
<dt>Subscription:</dt>
<dd className="text-cutoff">{subscriptionID}</dd>
</div>
</dl>
<div className="action-buttons">
<MutationButton
label={`Accept ${permType}`}
title={`Accept ${permType}`}
type="button"
className="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
accept({ id, permType });
}}
disabled={false}
showError={true}
result={acceptResult}
/>
<MutationButton
label={`Remove draft`}
title={`Remove draft`}
type="button"
className="button danger"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
remove({ id });
}}
disabled={false}
showError={true}
result={removeResult}
/>
</div>
</span>
);
}

View file

@ -0,0 +1,119 @@
/*
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 from "react";
import useFormSubmit from "../../../../lib/form/submit";
import { useCreateDomainPermissionDraftMutation } from "../../../../lib/query/admin/domain-permissions/drafts";
import { useBoolInput, useRadioInput, useTextInput } from "../../../../lib/form";
import { formDomainValidator } from "../../../../lib/util/formvalidators";
import MutationButton from "../../../../components/form/mutation-button";
import { Checkbox, RadioGroup, TextArea, TextInput } from "../../../../components/form/inputs";
import { useLocation } from "wouter";
import { DomainPermissionDraftDocsLink, DomainPermissionDraftHelpText } from "./common";
export default function DomainPermissionDraftNew() {
const [ _location, setLocation ] = useLocation();
const form = {
domain: useTextInput("domain", {
validator: formDomainValidator,
}),
permission_type: useRadioInput("permission_type", {
options: {
block: "Block domain",
allow: "Allow domain",
}
}),
obfuscate: useBoolInput("obfuscate"),
public_comment: useTextInput("public_comment"),
private_comment: useTextInput("private_comment"),
};
const [formSubmit, result] = useFormSubmit(
form,
useCreateDomainPermissionDraftMutation(),
{
changedOnly: false,
onFinish: (res) => {
if (res.data) {
// Creation successful,
// redirect to drafts overview.
setLocation(`/drafts/search`);
}
},
});
return (
<form
onSubmit={formSubmit}
// Prevent password managers
// trying to fill in fields.
autoComplete="off"
>
<div className="form-section-docs">
<h2>New Domain Permission Draft</h2>
<p><DomainPermissionDraftHelpText /></p>
<DomainPermissionDraftDocsLink />
</div>
<RadioGroup
field={form.permission_type}
/>
<TextInput
field={form.domain}
label={`Domain (without "https://" prefix)`}
placeholder="example.org"
autoCapitalize="none"
spellCheck="false"
/>
<TextArea
field={form.private_comment}
label={"Private comment"}
placeholder="This domain is like unto a clown car full of clowns, I suggest we block it forthwith."
autoCapitalize="sentences"
rows={3}
/>
<TextArea
field={form.public_comment}
label={"Public comment"}
placeholder="Bad posters"
autoCapitalize="sentences"
rows={3}
/>
<Checkbox
field={form.obfuscate}
label="Obfuscate domain in public lists"
/>
<MutationButton
label="Save"
result={result}
disabled={
!form.domain.value ||
!form.domain.valid ||
!form.permission_type.value
}
/>
</form>
);
}

View file

@ -0,0 +1,54 @@
/*
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 from "react";
export function DomainPermissionExcludeHelpText() {
return (
<>
Domain permission excludes prevent permissions for a domain (and all
subdomains) from being auomatically managed by domain permission subscriptions.
<br/>
For example, if you create an exclude entry for <code>example.org</code>, then
a blocklist or allowlist subscription will <em>exclude</em> entries for <code>example.org</code>
and any of its subdomains (<code>sub.example.org</code>, <code>another.sub.example.org</code> etc.)
when creating domain permission drafts and domain blocks/allows.
<br/>
This functionality allows you to manually manage permissions for excluded domains,
in cases where you know you definitely do or don't want to federate with a given domain,
no matter what entries are contained in a domain permission subscription.
<br/>
Note that by itself, creation of an exclude entry for a given domain does not affect
federation with that domain at all, it is only useful in combination with permission subscriptions.
</>
);
}
export function DomainPermissionExcludeDocsLink() {
return (
<a
href="https://docs.gotosocial.org/en/latest/admin/settings/#domain-permission-excludes"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about domain permission excludes (opens in a new tab)
</a>
);
}

View file

@ -0,0 +1,119 @@
/*
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 from "react";
import { useLocation, useParams } from "wouter";
import Loading from "../../../../components/loading";
import { useBaseUrl } from "../../../../lib/navigation/util";
import BackButton from "../../../../components/back-button";
import { Error as ErrorC } from "../../../../components/error";
import UsernameLozenge from "../../../../components/username-lozenge";
import { useDeleteDomainPermissionExcludeMutation, useGetDomainPermissionExcludeQuery } from "../../../../lib/query/admin/domain-permissions/excludes";
import MutationButton from "../../../../components/form/mutation-button";
export default function DomainPermissionExcludeDetail() {
const baseUrl = useBaseUrl();
const backLocation: string = history.state?.backLocation ?? `~${baseUrl}`;
const params = useParams();
let id = params.excludeId as string | undefined;
if (!id) {
throw "no perm ID";
}
const {
data: permExclude,
isLoading,
isFetching,
isError,
error,
} = useGetDomainPermissionExcludeQuery(id);
if (isLoading || isFetching) {
return <Loading />;
} else if (isError) {
return <ErrorC error={error} />;
} else if (permExclude === undefined) {
return <ErrorC error={new Error("permission exclude was undefined")} />;
}
const created = permExclude.created_at ? new Date(permExclude.created_at).toDateString(): "unknown";
const domain = permExclude.domain;
const privateComment = permExclude.private_comment ?? "[none]";
return (
<div className="domain-permission-exclude-details">
<h1><BackButton to={backLocation} /> Domain Permission Exclude Detail</h1>
<dl className="info-list">
<div className="info-list-entry">
<dt>Created</dt>
<dd><time dateTime={permExclude.created_at}>{created}</time></dd>
</div>
<div className="info-list-entry">
<dt>Created By</dt>
<dd>
<UsernameLozenge
account={permExclude.created_by}
linkTo={`~/settings/moderation/accounts/${permExclude.created_by}`}
backLocation={`~${location}`}
/>
</dd>
</div>
<div className="info-list-entry">
<dt>Domain</dt>
<dd>{domain}</dd>
</div>
<div className="info-list-entry">
<dt>Private comment</dt>
<dd>{privateComment}</dd>
</div>
</dl>
<HandleExclude
id={id}
backLocation={backLocation}
/>
</div>
);
}
function HandleExclude({ id, backLocation}: {id: string, backLocation: string}) {
const [_location, setLocation] = useLocation();
const [ deleteExclude, deleteResult ] = useDeleteDomainPermissionExcludeMutation();
return (
<MutationButton
label={`Delete exclude`}
title={`Delete exclude`}
type="button"
className="button danger"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
deleteExclude(id).then(res => {
if ("data" in res) {
setLocation(backLocation);
}
});
}}
disabled={false}
showError={true}
result={deleteResult}
/>
);
}

View file

@ -0,0 +1,235 @@
/*
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 { useDeleteDomainPermissionExcludeMutation, useLazySearchDomainPermissionExcludesQuery } from "../../../../lib/query/admin/domain-permissions/excludes";
import { DomainPerm } from "../../../../lib/types/domain-permission";
import { Error as ErrorC } from "../../../../components/error";
import { Select, TextInput } from "../../../../components/form/inputs";
import { formDomainValidator } from "../../../../lib/util/formvalidators";
import { DomainPermissionExcludeDocsLink, DomainPermissionExcludeHelpText } from "./common";
export default function DomainPermissionExcludesSearch() {
return (
<div className="domain-permission-excludes-view">
<div className="form-section-docs">
<h1>Domain Permission Excludes</h1>
<p>
You can use the form below to search through domain permission excludes.
<br/>
<DomainPermissionExcludeHelpText />
</p>
<DomainPermissionExcludeDocsLink />
</div>
<DomainPermissionExcludesSearchForm />
</div>
);
}
function DomainPermissionExcludesSearchForm() {
const [ location, setLocation ] = useLocation();
const search = useSearch();
const urlQueryParams = useMemo(() => new URLSearchParams(search), [search]);
const hasParams = urlQueryParams.size != 0;
const [ searchExcludes, searchRes ] = useLazySearchDomainPermissionExcludesQuery();
const form = {
domain: useTextInput("domain", {
defaultValue: urlQueryParams.get("domain") ?? "",
validator: formDomainValidator,
}),
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) {
searchExcludes(Object.fromEntries(urlQueryParams));
} else {
setLocation(location + "?limit=20");
}
}, [
urlQueryParams,
hasParams,
searchExcludes,
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(exclude: DomainPerm): ReactNode {
return (
<ExcludeListEntry
key={exclude.id}
permExclude={exclude}
linkTo={`/excludes/${exclude.id}`}
backLocation={backLocation}
/>
);
}
return (
<>
<form
onSubmit={submitQuery}
// Prevent password managers
// trying to fill in fields.
autoComplete="off"
>
<TextInput
field={form.domain}
label={`Domain (without "https://" prefix)`}
placeholder="example.org"
autoCapitalize="none"
spellCheck="false"
/>
<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?.excludes}
itemToEntry={itemToEntry}
isError={searchRes.isError}
error={searchRes.error}
emptyMessage={<b>No excludes found that match your query.</b>}
prevNextLinks={searchRes.data?.links}
/>
</>
);
}
interface ExcludeEntryProps {
permExclude: DomainPerm;
linkTo: string;
backLocation: string;
}
function ExcludeListEntry({ permExclude, linkTo, backLocation }: ExcludeEntryProps) {
const [ _location, setLocation ] = useLocation();
const [ deleteExclude, deleteResult ] = useDeleteDomainPermissionExcludeMutation();
const domain = permExclude.domain;
const privateComment = permExclude.private_comment ?? "[none]";
const id = permExclude.id;
if (!id) {
return <ErrorC error={new Error("id was undefined")} />;
}
return (
<span
className={`pseudolink domain-permission-exclude entry`}
aria-label={`Exclude ${domain}`}
title={`Exclude ${domain}`}
onClick={() => {
// When clicking on a exclude, direct
// to the detail view for that exclude.
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">
<div className="info-list-entry">
<dt>Domain:</dt>
<dd className="text-cutoff">{domain}</dd>
</div>
<div className="info-list-entry">
<dt>Private comment:</dt>
<dd className="text-cutoff">{privateComment}</dd>
</div>
</dl>
<div className="action-buttons">
<MutationButton
label={`Delete exclude`}
title={`Delete exclude`}
type="button"
className="button danger"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
deleteExclude(id);
}}
disabled={false}
showError={true}
result={deleteResult}
/>
</div>
</span>
);
}

View file

@ -0,0 +1,90 @@
/*
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 from "react";
import useFormSubmit from "../../../../lib/form/submit";
import { useCreateDomainPermissionExcludeMutation } from "../../../../lib/query/admin/domain-permissions/excludes";
import { useTextInput } from "../../../../lib/form";
import { formDomainValidator } from "../../../../lib/util/formvalidators";
import MutationButton from "../../../../components/form/mutation-button";
import { TextArea, TextInput } from "../../../../components/form/inputs";
import { useLocation } from "wouter";
import { DomainPermissionExcludeDocsLink, DomainPermissionExcludeHelpText } from "./common";
export default function DomainPermissionExcludeNew() {
const [ _location, setLocation ] = useLocation();
const form = {
domain: useTextInput("domain", {
validator: formDomainValidator,
}),
private_comment: useTextInput("private_comment"),
};
const [formSubmit, result] = useFormSubmit(
form,
useCreateDomainPermissionExcludeMutation(),
{
changedOnly: false,
onFinish: (res) => {
if (res.data) {
// Creation successful,
// redirect to excludes overview.
setLocation(`/excludes/search`);
}
},
});
return (
<form
onSubmit={formSubmit}
// Prevent password managers
// trying to fill in fields.
autoComplete="off"
>
<div className="form-section-docs">
<h2>New Domain Permission Exclude</h2>
<p><DomainPermissionExcludeHelpText /></p>
<DomainPermissionExcludeDocsLink />
</div>
<TextInput
field={form.domain}
label={`Domain (without "https://" prefix)`}
placeholder="example.org"
autoCapitalize="none"
spellCheck="false"
/>
<TextArea
field={form.private_comment}
label={"Private comment"}
placeholder="Created an exclude for this domain because we should manage it manually."
autoCapitalize="sentences"
rows={3}
/>
<MutationButton
label="Save"
result={result}
disabled={!form.domain.value || !form.domain.valid}
/>
</form>
);
}

View file

@ -30,6 +30,7 @@ import type { MappedDomainPerms } from "../../../lib/types/domain-permission";
import { NoArg } from "../../../lib/types/query";
import { PermType } from "../../../lib/types/perm";
import { useBaseUrl } from "../../../lib/navigation/util";
import { useCapitalize } from "../../../lib/util";
export default function DomainPermissionsOverview() {
const baseUrl = useBaseUrl();
@ -42,9 +43,7 @@ export default function DomainPermissionsOverview() {
const permType = params.permType.slice(0, -1) as PermType;
// Uppercase first letter of given permType.
const permTypeUpper = useMemo(() => {
return permType.charAt(0).toUpperCase() + permType.slice(1);
}, [permType]);
const permTypeUpper = useCapitalize(permType);
// Fetch / wait for desired perms to load.
const { data: blocks, isLoading: isLoadingBlocks } = useDomainBlocksQuery(NoArg, { skip: permType !== "block" });

View file

@ -116,6 +116,40 @@ function ModerationDomainPermsMenu() {
itemUrl="import-export"
icon="fa-floppy-o"
/>
<MenuItem
name="Drafts"
itemUrl="drafts"
defaultChild="search"
icon="fa-pencil"
>
<MenuItem
name="Search"
itemUrl="search"
icon="fa-list"
/>
<MenuItem
name="New draft"
itemUrl="new"
icon="fa-plus"
/>
</MenuItem>
<MenuItem
name="Excludes"
itemUrl="excludes"
defaultChild="search"
icon="fa-minus-square"
>
<MenuItem
name="Search"
itemUrl="search"
icon="fa-list"
/>
<MenuItem
name="New exclude"
itemUrl="new"
icon="fa-plus"
/>
</MenuItem>
</MenuItem>
);
}

View file

@ -25,7 +25,7 @@ import { useValue, useTextInput } from "../../../lib/form";
import useFormSubmit from "../../../lib/form/submit";
import { TextArea } from "../../../components/form/inputs";
import MutationButton from "../../../components/form/mutation-button";
import Username from "../../../components/username";
import UsernameLozenge from "../../../components/username-lozenge";
import { useGetReportQuery, useResolveReportMutation } from "../../../lib/query/admin/reports";
import { useBaseUrl } from "../../../lib/navigation/util";
import { AdminReport } from "../../../lib/types/report";
@ -99,7 +99,7 @@ function ReportBasicInfo({ report, baseUrl, location }: ReportSectionProps) {
<div className="info-list-entry">
<dt>Reported account</dt>
<dd>
<Username
<UsernameLozenge
account={target}
linkTo={`~/settings/moderation/accounts/${target.id}`}
backLocation={`~${baseUrl}${location}`}
@ -110,7 +110,7 @@ function ReportBasicInfo({ report, baseUrl, location }: ReportSectionProps) {
<div className="info-list-entry">
<dt>Reported by</dt>
<dd>
<Username
<UsernameLozenge
account={from}
linkTo={`~/settings/moderation/accounts/${from.id}`}
backLocation={`~${baseUrl}${location}`}
@ -173,7 +173,7 @@ function ReportHistory({ report, baseUrl, location }: ReportSectionProps) {
<div className="info-list-entry">
<dt>Handled by</dt>
<dd>
<Username
<UsernameLozenge
account={handled_by}
linkTo={`~/settings/moderation/accounts/${handled_by.id}`}
backLocation={`~${baseUrl}${location}`}

View file

@ -25,7 +25,7 @@ import { PageableList } from "../../../components/pageable-list";
import { Select } from "../../../components/form/inputs";
import MutationButton from "../../../components/form/mutation-button";
import { useLocation, useSearch } from "wouter";
import Username from "../../../components/username";
import UsernameLozenge from "../../../components/username-lozenge";
import { AdminReport } from "../../../lib/types/report";
export default function ReportsSearch() {
@ -206,7 +206,7 @@ function ReportListEntry({ report, linkTo, backLocation }: ReportEntryProps) {
<div className="info-list-entry">
<dt>Reported account:</dt>
<dd className="text-cutoff">
<Username
<UsernameLozenge
account={target}
classNames={["text-cutoff report-byline"]}
/>
@ -216,7 +216,7 @@ function ReportListEntry({ report, linkTo, backLocation }: ReportEntryProps) {
<div className="info-list-entry">
<dt>Reported by:</dt>
<dd className="text-cutoff reported-by">
<Username account={from} />
<UsernameLozenge account={from} />
</dd>
</div>

View file

@ -29,6 +29,12 @@ import DomainPermDetail from "./domain-permissions/detail";
import AccountsSearch from "./accounts";
import AccountsPending from "./accounts/pending";
import AccountDetail from "./accounts/detail";
import DomainPermissionDraftsSearch from "./domain-permissions/drafts";
import DomainPermissionDraftNew from "./domain-permissions/drafts/new";
import DomainPermissionDraftDetail from "./domain-permissions/drafts/detail";
import DomainPermissionExcludeDetail from "./domain-permissions/excludes/detail";
import DomainPermissionExcludesSearch from "./domain-permissions/excludes";
import DomainPermissionExcludeNew from "./domain-permissions/excludes/new";
/*
EXPORTED COMPONENTS
@ -139,6 +145,12 @@ function ModerationDomainPermsRouter() {
<Switch>
<Route path="/import-export" component={ImportExport} />
<Route path="/process" component={ImportExport} />
<Route path="/drafts/search" component={DomainPermissionDraftsSearch} />
<Route path="/drafts/new" component={DomainPermissionDraftNew} />
<Route path="/drafts/:permDraftId" component={DomainPermissionDraftDetail} />
<Route path="/excludes/search" component={DomainPermissionExcludesSearch} />
<Route path="/excludes/new" component={DomainPermissionExcludeNew} />
<Route path="/excludes/:excludeId" component={DomainPermissionExcludeDetail} />
<Route path="/:permType" component={DomainPermissionsOverview} />
<Route path="/:permType/:domain" component={DomainPermDetail} />
<Route><Redirect to="/blocks"/></Route>