[feature] Add domain limit DB model and API endpoints (#4554)

# Description

> If this is a code change, please include a summary of what you've coded, and link to the issue(s) it closes/implements.
>
> If this is a documentation change, please briefly describe what you've changed and why.

This pull request adds database models, (support) functions, migration, and API models + endpoints for upcoming domain limit functionality.

Part of https://codeberg.org/superseriousbusiness/gotosocial/issues/2676, https://codeberg.org/superseriousbusiness/gotosocial/issues/9, and https://codeberg.org/superseriousbusiness/gotosocial/issues/1730

## Checklist

Please put an x inside each checkbox to indicate that you've read and followed it: `[ ]` -> `[x]`

If this is a documentation change, only the first checkbox must be filled (you can delete the others if you want).

- [x] I/we have read the [GoToSocial contribution guidelines](https://codeberg.org/superseriousbusiness/gotosocial/src/branch/main/CONTRIBUTING.md).
- [x] I/we have discussed the proposed changes already, either in an issue on the repository, or in the Matrix chat.
- [x] I/we have not leveraged AI to create the proposed changes.
- [x] I/we have performed a self-review of added code.
- [x] I/we have written code that is legible and maintainable by others.
- [x] I/we have commented the added code, particularly in hard-to-understand areas.
- [x] I/we have made any necessary changes to documentation.
- [x] I/we have added tests that cover new code.
- [x] I/we have run tests and they pass locally with the changes.
- [x] I/we have run `go fmt ./...` and `golangci-lint run`.

Reviewed-on: https://codeberg.org/superseriousbusiness/gotosocial/pulls/4554
Co-authored-by: tobi <tobi.smethurst@protonmail.com>
Co-committed-by: tobi <tobi.smethurst@protonmail.com>
This commit is contained in:
tobi
2025-11-13 15:28:56 +01:00
committed by tobi
parent 70edae8252
commit fd2f293a9e
32 changed files with 3226 additions and 413 deletions
+8
View File
@@ -36,6 +36,8 @@ const (
DomainBlocksPathWithID = DomainBlocksPath + "/:" + apiutil.IDKey
DomainAllowsPath = BasePath + "/domain_allows"
DomainAllowsPathWithID = DomainAllowsPath + "/:" + apiutil.IDKey
DomainLimitsPath = BasePath + "/domain_limits"
DomainLimitsPathWithID = DomainLimitsPath + "/:" + apiutil.IDKey
DomainPermissionDraftsPath = BasePath + "/domain_permission_drafts"
DomainPermissionDraftsPathWithID = DomainPermissionDraftsPath + "/:" + apiutil.IDKey
DomainPermissionDraftAcceptPath = DomainPermissionDraftsPathWithID + "/accept"
@@ -112,6 +114,12 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H
attachHandler(http.MethodPut, DomainAllowsPathWithID, m.DomainAllowUpdatePUTHandler)
attachHandler(http.MethodDelete, DomainAllowsPathWithID, m.DomainAllowDELETEHandler)
// domain limits stuff
attachHandler(http.MethodGet, DomainLimitsPath, m.DomainLimitsGETHandler)
attachHandler(http.MethodPost, DomainLimitsPath, m.DomainLimitsPOSTHandler)
attachHandler(http.MethodPut, DomainLimitsPathWithID, m.DomainLimitPUTHandler)
attachHandler(http.MethodDelete, DomainLimitsPathWithID, m.DomainLimitDELETEHandler)
// domain permission draft stuff
attachHandler(http.MethodPost, DomainPermissionDraftsPath, m.DomainPermissionDraftsPOSTHandler)
attachHandler(http.MethodGet, DomainPermissionDraftsPath, m.DomainPermissionDraftsGETHandler)
@@ -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/>.
package admin
import (
"errors"
"fmt"
"net/http"
apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model"
apiutil "code.superseriousbusiness.org/gotosocial/internal/api/util"
"code.superseriousbusiness.org/gotosocial/internal/gtserror"
"code.superseriousbusiness.org/gotosocial/internal/util"
"github.com/gin-gonic/gin"
)
// DomainLimitsPOSTHandler swagger:operation POST /api/v1/admin/domain_limits domainLimitCreate
//
// Create a domain limit.
//
// ---
// tags:
// - admin
//
// consumes:
// - multipart/form-data
// - application/json
//
// produces:
// - application/json
//
// parameters:
// -
// name: domain
// in: formData
// description: Hostname of the domain to limit.
// type: string
// required: true
// -
// name: media_policy
// in: formData
// description: |-
// Policy to apply to media files originating from the limited domain.
// No action = default (not limited).
// Mark sensitive = mark all media from the limited domain as sensitive.
// Reject = do not download media from the limited domain. Serve a link to the media instead.
// type: string
// enum:
// - no_action
// - mark_sensitive
// - reject
// default: no_action
// -
// name: follows_policy
// in: formData
// description: |-
// Policy to apply to follow (requests) originating from the limited domain.
// No action = default (not limited).
// Manual approval = require manual approval for all follows from limited domain.
// Reject non mutual = automatically reject follows from the limited domain when they're not follow-backs.
// Reject all = automatically reject all follows from the limited domain.
// type: string
// enum:
// - no_action
// - manual_approval
// - reject_non_mutual
// - reject_all
// default: no_action
// -
// name: statuses_policy
// in: formData
// description: |-
// Policy to apply to statuses of non-followed accounts on the limited domain.
// No action = default (not limited).
// Filter warn = trigger a warn filter pointing to this domain limit.
// Filter hide = trigger a hide filter pointing to this domain limit.
// type: string
// enum:
// - no_action
// - filter_warn
// - filter_hide
// default: no_action
// -
// name: accounts_policy
// in: formData
// description: |-
// Policy to apply to non-followed accounts on the limited domain.
// No action = default (not limited).
// Mute = mute all non-followed accounts on the limited domain.
// type: string
// enum:
// - no_action
// - mute
// default: no_action
// -
// name: content_warning
// in: formData
// description: Content warning to prepend to posts from accounts on this instance.
// type: string
// -
// name: public_comment
// in: formData
// description: >-
// Public comment about this domain limit.
// This will be displayed alongside the domain limit if you choose to share limits.
// type: string
// -
// name: private_comment
// in: formData
// description: >-
// Private comment about this domain limit. Will only be shown to other admins, so this
// is a useful way of internally keeping track of why a certain domain ended up limited.
// type: string
//
// security:
// - OAuth2 Bearer:
// - admin:write:domain_limits
//
// responses:
// '200':
// description: The newly created domain limit.
// schema:
// "$ref": "#/definitions/domainLimit"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '403':
// description: forbidden
// '404':
// description: not found
// '406':
// description: not acceptable
// '409':
// description: There is already a limit in place for this domain.
// '500':
// description: internal server error
func (m *Module) DomainLimitsPOSTHandler(c *gin.Context) {
authed, errWithCode := apiutil.TokenAuth(c,
true, true, true, true,
apiutil.ScopeAdminWriteDomainLimits,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, 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
}
form := new(apimodel.DomainLimitRequest)
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
}
domainLimit, errWithCode := m.processor.Admin().DomainLimitCreate(
c.Request.Context(),
authed.Account,
form.Domain,
util.PtrOrValue(form.MediaPolicy, apimodel.MediaPolicyNoAction),
util.PtrOrValue(form.FollowsPolicy, apimodel.FollowsPolicyNoAction),
util.PtrOrValue(form.StatusesPolicy, apimodel.StatusesPolicyNoAction),
util.PtrOrValue(form.AccountsPolicy, apimodel.AccountsPolicyNoAction),
util.PtrOrZero(form.ContentWarning),
util.PtrOrZero(form.PublicComment),
util.PtrOrZero(form.PrivateComment),
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, domainLimit)
}
@@ -0,0 +1,111 @@
// 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"
apiutil "code.superseriousbusiness.org/gotosocial/internal/api/util"
"code.superseriousbusiness.org/gotosocial/internal/gtserror"
"github.com/gin-gonic/gin"
)
// DomainLimitDELETEHandler swagger:operation DELETE /api/v1/admin/domain_limits/{id} domainLimitDelete
//
// Delete domain limit with the given ID.
//
// ---
// tags:
// - admin
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// type: string
// description: The id of the domain limit.
// in: path
// required: true
//
// security:
// - OAuth2 Bearer:
// - admin:write:domain_limits
//
// responses:
// '200':
// description: The domain limit that was just deleted.
// schema:
// "$ref": "#/definitions/domainLimit"
// '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) DomainLimitDELETEHandler(c *gin.Context) {
authed, errWithCode := apiutil.TokenAuth(c,
true, true, true, true,
apiutil.ScopeAdminWriteDomainLimits,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, 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
}
domainLimit, errWithCode := m.processor.Admin().DomainLimitDelete(
c.Request.Context(),
id,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, domainLimit)
}
@@ -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"
apiutil "code.superseriousbusiness.org/gotosocial/internal/api/util"
"code.superseriousbusiness.org/gotosocial/internal/gtserror"
"code.superseriousbusiness.org/gotosocial/internal/paging"
"github.com/gin-gonic/gin"
)
// DomainLimitsGETHandler swagger:operation GET /api/v1/admin/domain_limits domainLimitsGet
//
// View domain limits currently in place.
//
// By default, items will be returned in alphabetical order by domain.
//
// Paging is optional for this endpoint. To page, use the `limit`, `max_id` and/or `min_id` params. Else, all items will be returned.
//
// If paging, the next and previous queries can be parsed from the returned Link header.
//
// Example:
//
// ```
// <https://example.org/api/v1/admin/domain_limits?limit=20&max_id=01FC0SKA48HNSVR6YKZCQGS2V8>; rel="next", <https://example.org/api/v1/admin/domain_limits?limit=20&min_id=01FC0SKW5JK2Q4EVAV2B462YY0>; rel="prev"
// ````
//
// If paging, items will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer).
//
// ---
// tags:
// - admin
//
// produces:
// - application/json
//
// parameters:
// -
// 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. Use 0 to return all (no paging).
// default: 20
// minimum: 0
// maximum: 100
// in: query
//
// security:
// - OAuth2 Bearer:
// - admin:read:domain_limits
//
// responses:
// '200':
// description: Domain limits.
// schema:
// type: array
// items:
// "$ref": "#/definitions/domainLimit"
// headers:
// Link:
// type: string
// description: Links to the next and previous queries. Omitted if empty page or not paging.
// '400':
// description: bad request
// '401':
// description: unauthorized
// '403':
// description: forbidden
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) DomainLimitsGETHandler(c *gin.Context) {
authed, errWithCode := apiutil.TokenAuth(c,
true, true, true, true,
apiutil.ScopeAdminReadDomainLimits,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, 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 _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
// Allow paging but don't
// enforce or use it by default.
page, errWithCode := paging.ParseIDPage(c,
0, // min items
100, // max items
0, // default (no paging)
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
resp, errWithCode := m.processor.Admin().DomainLimitsGet(
c.Request.Context(),
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)
}
@@ -0,0 +1,222 @@
// 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"
apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model"
apiutil "code.superseriousbusiness.org/gotosocial/internal/api/util"
"code.superseriousbusiness.org/gotosocial/internal/gtserror"
"github.com/gin-gonic/gin"
)
// DomainLimitUpdatePUTHandler swagger:operation PUT /api/v1/admin/domain_limits/{id} domainLimitUpdate
//
// Update a domain limit.
//
// ---
// tags:
// - admin
//
// consumes:
// - multipart/form-data
// - application/json
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// type: string
// description: The id of the domain limit.
// in: path
// required: true
// -
// name: media_policy
// in: formData
// description: |-
// Policy to apply to media files originating from the limited domain.
// No action = default (not limited).
// Mark sensitive = mark all media from the limited domain as sensitive.
// Reject = do not download media from the limited domain. Serve a link to the media instead.
// Omit to keep current value.
// type: string
// enum:
// - no_action
// - mark_sensitive
// - reject
// -
// name: follows_policy
// in: formData
// description: |-
// Policy to apply to follow (requests) originating from the limited domain.
// No action = default (not limited).
// Manual approval = require manual approval for all follows from limited domain.
// Reject non mutual = automatically reject follows from the limited domain when they're not follow-backs.
// Reject all = automatically reject all follows from the limited domain.
// Omit to keep current value.
// type: string
// enum:
// - no_action
// - manual_approval
// - reject_non_mutual
// - reject_all
// -
// name: statuses_policy
// in: formData
// description: |-
// Policy to apply to statuses of non-followed accounts on the limited domain.
// No action = default (not limited).
// Filter warn = trigger a warn filter pointing to this domain limit.
// Filter hide = trigger a hide filter pointing to this domain limit.
// Omit to keep current value.
// type: string
// enum:
// - no_action
// - filter_warn
// - filter_hide
// -
// name: accounts_policy
// in: formData
// description: |-
// Policy to apply to non-followed accounts on the limited domain.
// No action = default (not limited).
// Mute = mute all non-followed accounts on the limited domain.
// Omit to keep current value.
// type: string
// enum:
// - no_action
// - mute
// default: no_action
// -
// name: content_warning
// in: formData
// description: Content warning to prepend to posts from accounts on this instance. Omit to keep current value.
// type: string
// -
// name: public_comment
// in: formData
// description: >-
// Public comment about this domain limit.
// This will be displayed alongside the domain limit if you choose to share limits.
// Omit to keep current value.
// type: string
// -
// name: private_comment
// in: formData
// description: >-
// Private comment about this domain limit. Will only be shown to other admins, so this
// is a useful way of internally keeping track of why a certain domain ended up limited.
// Omit to keep current value.
// type: string
//
// security:
// - OAuth2 Bearer:
// - admin:write:domain_limits
//
// responses:
// '200':
// description: The updated domain limit.
// schema:
// "$ref": "#/definitions/domainLimit"
// '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) DomainLimitPUTHandler(c *gin.Context) {
authed, errWithCode := apiutil.TokenAuth(c,
true, true, true, true,
apiutil.ScopeAdminWriteDomainLimits,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, 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
}
form := new(apimodel.DomainLimitRequest)
if err := c.ShouldBind(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
// Ensure something is set to update.
if form.MediaPolicy == nil &&
form.FollowsPolicy == nil &&
form.StatusesPolicy == nil &&
form.AccountsPolicy == nil &&
form.ContentWarning == nil &&
form.PublicComment == nil &&
form.PrivateComment == nil {
const text = "nothing to update; at least one of media_policy, follows_policy, statuses_policy, accounts_policy, content_warning, public_comment, or private_comment must be set"
errWithCode := gtserror.NewErrorBadRequest(errors.New(text), text)
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
domainLimit, errWithCode := m.processor.Admin().DomainLimitUpdate(
c.Request.Context(),
id,
form.MediaPolicy,
form.FollowsPolicy,
form.StatusesPolicy,
form.AccountsPolicy,
form.ContentWarning,
form.PublicComment,
form.PrivateComment,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, domainLimit)
}