[feature] Instance rules (#2125)

* init instance rules database model, admin api

* expose instance rules in public instance api

* public /api/v1/instance/rules route

* GET ruleById

* createRule route

* createRule auth check

* updateRule

* deleteRule

* list rules on about page

* ruleGet auth

* add about page ids for anchors

* process and store adding violated rules to reports

* admin api models for instance rules

* instance rule edit frontend

* change rule inputs to textareas

* database fixes after rebase (#2124)

* remove unused imports

* fix db migration column name

* fix tests

* fix more tests

* fix postgres error with wrongly used Ident

* add some tests, fiddle with rule model a bit, fix postgres migration

* swagger docs

---------

Co-authored-by: tsmethurst <tobi.smethurst@protonmail.com>
This commit is contained in:
f0x52 2023-08-19 14:33:15 +02:00 committed by GitHub
parent d5d6ad406f
commit 92de8fb396
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
49 changed files with 2189 additions and 107 deletions

View file

@ -566,11 +566,12 @@ definitions:
example: 01FBVD42CQ3ZEEVMW180SBX03B
type: string
x-go-name: ID
rule_ids:
rules:
description: |-
Array of rule IDs that were submitted along with this report.
NOT IMPLEMENTED, will always be empty array.
items: {}
Array of rules that were broken according to this report.
Will be empty if no rule IDs were submitted with the report.
items:
$ref: '#/definitions/instanceRule'
type: array
x-go-name: Rules
statuses:
@ -1274,6 +1275,36 @@ definitions:
type: object
x-go-name: InstanceConfigurationStatuses
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
instanceRule:
properties:
id:
type: string
x-go-name: ID
text:
type: string
x-go-name: Text
title: InstanceRule represents a single instance rule.
type: object
x-go-name: InstanceRule
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
instanceRuleCreateRequest:
properties:
Text:
type: string
title: InstanceRuleCreateRequest represents a request to create a new instance rule, made through the admin API.
type: object
x-go-name: InstanceRuleCreateRequest
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
instanceRuleUpdateRequest:
properties:
ID:
type: string
Text:
type: string
title: InstanceRuleUpdateRequest represents a request to update the text of an instance rule, made through the admin API.
type: object
x-go-name: InstanceRuleUpdateRequest
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
instanceV1:
properties:
account_domain:
@ -1330,6 +1361,12 @@ definitions:
description: New account registrations are enabled on this instance.
type: boolean
x-go-name: Registrations
rules:
description: An itemized list of rules for this instance.
items:
$ref: '#/definitions/instanceRule'
type: array
x-go-name: Rules
short_description:
description: |-
A shorter description of the instance.
@ -1453,10 +1490,9 @@ definitions:
registrations:
$ref: '#/definitions/instanceV2Registrations'
rules:
description: |-
An itemized list of rules for this website.
Currently not implemented (will always be empty array).
items: {}
description: An itemized list of rules for this instance.
items:
$ref: '#/definitions/instanceRule'
type: array
x-go-name: Rules
source_url:
@ -1755,6 +1791,72 @@ definitions:
type: object
x-go-name: MediaMeta
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
multiStatus:
description: |-
This model should be transmitted along with http code
207 MULTI-STATUS to indicate a mixture of responses.
See https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/207
properties:
data:
items:
$ref: '#/definitions/multiStatusEntry'
type: array
x-go-name: Data
metadata:
$ref: '#/definitions/multiStatusMetadata'
title: MultiStatus models a multistatus HTTP response body.
type: object
x-go-name: MultiStatus
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
multiStatusEntry:
description: |-
It can model either a success or a failure. The type
and value of `Resource` is left to the discretion of
the caller, but at minimum it should be expected to be
JSON-serializable.
properties:
message:
description: Message/error message for this entry.
type: string
x-go-name: Message
resource:
description: |-
The resource/result for this entry.
Value may be any type, check the docs
per endpoint to see which to expect.
x-go-name: Resource
status:
description: HTTP status code of this entry.
format: int64
type: integer
x-go-name: Status
title: MultiStatusEntry models one entry in multistatus data.
type: object
x-go-name: MultiStatusEntry
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
multiStatusMetadata:
description: |-
MultiStatusMetadata models an at-a-glance summary of
the data contained in the MultiStatus.
properties:
failure:
description: Count of unsuccessful results (!2xx).
format: int64
type: integer
x-go-name: Failure
success:
description: Count of successful results (2xx).
format: int64
type: integer
x-go-name: Success
total:
description: Success count + failure count.
format: int64
type: integer
x-go-name: Total
type: object
x-go-name: MultiStatusMetadata
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
nodeinfo:
description: 'See: https://nodeinfo.diaspora.software/schema.html'
properties:
@ -1971,11 +2073,10 @@ definitions:
Array of rule IDs that were submitted along with this report.
Will be empty if no rule IDs were submitted.
example:
- 1
- 2
- 01GPBN5YDY6JKBWE44H7YQBDCQ
- 01GPBN65PDWSBPWVDD0SQCFFY3
items:
format: int64
type: integer
type: string
type: array
x-go-name: RuleIDs
status_ids:
@ -4036,6 +4137,118 @@ paths:
summary: Send a generic test email to a specified email address.
tags:
- admin
/api/v1/admin/instance/rules:
post:
consumes:
- multipart/form-data
operationId: ruleCreate
parameters:
- description: Text body for the instance rule, plaintext.
in: formData
name: text
required: true
type: string
produces:
- application/json
responses:
"200":
description: The newly-created instance rule.
schema:
$ref: '#/definitions/instanceRule'
"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: Create a new instance rule.
tags:
- admin
/api/v1/admin/instance/rules{id}:
delete:
consumes:
- multipart/form-data
operationId: ruleDelete
parameters:
- description: The id of the rule to delete.
in: formData
name: id
required: true
type: path
produces:
- application/json
responses:
"200":
description: The deleted instance rule.
schema:
$ref: '#/definitions/instanceRule'
"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: Delete an existing instance rule.
tags:
- admin
patch:
consumes:
- multipart/form-data
operationId: ruleUpdate
parameters:
- description: The id of the rule to update.
in: formData
name: id
required: true
type: path
- description: Text body for the updated instance rule, plaintext.
in: formData
name: text
required: true
type: string
produces:
- application/json
responses:
"200":
description: The updated instance rule.
schema:
$ref: '#/definitions/instanceRule'
"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: Update an existing instance rule.
tags:
- admin
/api/v1/admin/media_cleanup:
post:
consumes:
@ -4251,6 +4464,67 @@ paths:
summary: Mark a report as resolved.
tags:
- admin
/api/v1/admin/rules:
get:
description: The rules will be returned in order (sorted by Order ascending).
operationId: rules
produces:
- application/json
responses:
"200":
description: An array with all the rules for the local instance.
schema:
items:
$ref: '#/definitions/instanceRule'
type: array
"400":
description: bad request
"401":
description: unauthorized
"404":
description: not found
"406":
description: not acceptable
"500":
description: internal server error
security:
- OAuth2 Bearer:
- admin
summary: View instance rules, with IDs.
tags:
- admin
/api/v1/admin/rules/{id}:
get:
operationId: adminRuleGet
parameters:
- description: The id of the rule.
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: The requested rule.
schema:
$ref: '#/definitions/instanceRule'
"400":
description: bad request
"401":
description: unauthorized
"404":
description: not found
"406":
description: not acceptable
"500":
description: internal server error
security:
- OAuth2 Bearer:
- admin
summary: View instance rule with the given id.
tags:
- admin
/api/v1/apps:
post:
consumes:
@ -4750,6 +5024,30 @@ paths:
description: internal server error
tags:
- instance
/api/v1/instance/rules:
get:
description: The rules will be returned in order (sorted by Order ascending).
operationId: rules
produces:
- application/json
responses:
"200":
description: An array with all the rules for the local instance.
schema:
items:
$ref: '#/definitions/instanceRule'
type: array
"400":
description: bad request
"404":
description: not found
"406":
description: not acceptable
"500":
description: internal server error
summary: View instance rules (public).
tags:
- instance
/api/v1/lists:
get:
operationId: lists
@ -5505,17 +5803,13 @@ paths:
name: category
type: string
x-go-name: Category
- description: |-
IDs of rules on this instance which have been broken according to the reporter.
This is currently not supported, provided only for API compatibility.
- description: IDs of rules on this instance which have been broken according to the reporter.
example:
- 1
- 2
- 3
- 01GPBN5YDY6JKBWE44H7YQBDCQ
- 01GPBN65PDWSBPWVDD0SQCFFY3
in: formData
items:
format: int64
type: integer
type: string
name: rule_ids
type: array
x-go-name: RuleIDs

View file

@ -25,22 +25,24 @@
)
const (
BasePath = "/v1/admin"
EmojiPath = BasePath + "/custom_emojis"
EmojiPathWithID = EmojiPath + "/:" + IDKey
EmojiCategoriesPath = EmojiPath + "/categories"
DomainBlocksPath = BasePath + "/domain_blocks"
DomainBlocksPathWithID = DomainBlocksPath + "/:" + IDKey
AccountsPath = BasePath + "/accounts"
AccountsPathWithID = AccountsPath + "/:" + IDKey
AccountsActionPath = AccountsPathWithID + "/action"
MediaCleanupPath = BasePath + "/media_cleanup"
MediaRefetchPath = BasePath + "/media_refetch"
ReportsPath = BasePath + "/reports"
ReportsPathWithID = ReportsPath + "/:" + IDKey
ReportsResolvePath = ReportsPathWithID + "/resolve"
EmailPath = BasePath + "/email"
EmailTestPath = EmailPath + "/test"
BasePath = "/v1/admin"
EmojiPath = BasePath + "/custom_emojis"
EmojiPathWithID = EmojiPath + "/:" + IDKey
EmojiCategoriesPath = EmojiPath + "/categories"
DomainBlocksPath = BasePath + "/domain_blocks"
DomainBlocksPathWithID = DomainBlocksPath + "/:" + IDKey
AccountsPath = BasePath + "/accounts"
AccountsPathWithID = AccountsPath + "/:" + IDKey
AccountsActionPath = AccountsPathWithID + "/action"
MediaCleanupPath = BasePath + "/media_cleanup"
MediaRefetchPath = BasePath + "/media_refetch"
ReportsPath = BasePath + "/reports"
ReportsPathWithID = ReportsPath + "/:" + IDKey
ReportsResolvePath = ReportsPathWithID + "/resolve"
EmailPath = BasePath + "/email"
EmailTestPath = EmailPath + "/test"
InstanceRulesPath = BasePath + "/instance/rules"
InstanceRulesPathWithID = InstanceRulesPath + "/:" + IDKey
IDKey = "id"
FilterQueryKey = "filter"
@ -95,4 +97,11 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H
// email stuff
attachHandler(http.MethodPost, EmailTestPath, m.EmailTestPOSTHandler)
// instance rules stuff
attachHandler(http.MethodGet, InstanceRulesPath, m.RulesGETHandler)
attachHandler(http.MethodGet, InstanceRulesPathWithID, m.RuleGETHandler)
attachHandler(http.MethodPost, InstanceRulesPath, m.RulePOSTHandler)
attachHandler(http.MethodPatch, InstanceRulesPathWithID, m.RulePATCHHandler)
attachHandler(http.MethodDelete, InstanceRulesPathWithID, m.RuleDELETEHandler)
}

View file

@ -335,7 +335,7 @@ func (suite *ReportsGetTestSuite) TestReportsGetAll() {
"created_by_application_id": "01F8MGXQRHYF5QPMTMXP78QC2F"
},
"statuses": [],
"rule_ids": [],
"rules": [],
"action_taken_comment": "user was warned not to be a turtle anymore"
},
{
@ -528,7 +528,16 @@ func (suite *ReportsGetTestSuite) TestReportsGetAll() {
"poll": null
}
],
"rule_ids": [],
"rules": [
{
"id": "01GP3AWY4CRDVRNZKW0TEAMB51",
"text": "Be gay"
},
{
"id": "01GP3DFY9XQ1TJMZT5BGAZPXX3",
"text": "Do crime"
}
],
"action_taken_comment": null
}
]`, string(b))
@ -740,7 +749,16 @@ func (suite *ReportsGetTestSuite) TestReportsGetCreatedByAccount() {
"poll": null
}
],
"rule_ids": [],
"rules": [
{
"id": "01GP3AWY4CRDVRNZKW0TEAMB51",
"text": "Be gay"
},
{
"id": "01GP3DFY9XQ1TJMZT5BGAZPXX3",
"text": "Do crime"
}
],
"action_taken_comment": null
}
]`, string(b))
@ -952,7 +970,16 @@ func (suite *ReportsGetTestSuite) TestReportsGetTargetAccount() {
"poll": null
}
],
"rule_ids": [],
"rules": [
{
"id": "01GP3AWY4CRDVRNZKW0TEAMB51",
"text": "Be gay"
},
{
"id": "01GP3DFY9XQ1TJMZT5BGAZPXX3",
"text": "Do crime"
}
],
"action_taken_comment": null
}
]`, string(b))

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 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/oauth"
)
// RulePOSTHandler swagger:operation POST /api/v1/admin/instance/rules ruleCreate
//
// Create a new instance rule.
//
// ---
// tags:
// - admin
//
// consumes:
// - multipart/form-data
//
// produces:
// - application/json
//
// parameters:
// -
// name: text
// in: formData
// description: >-
// Text body for the instance rule, plaintext.
// type: string
// required: true
//
// security:
// - OAuth2 Bearer:
// - admin
//
// responses:
// '200':
// description: The newly-created instance rule.
// schema:
// "$ref": "#/definitions/instanceRule"
// '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) RulePOSTHandler(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 _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
form := &apimodel.InstanceRuleCreateRequest{}
if err := c.ShouldBind(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
if err := validateCreateRule(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
apiRule, errWithCode := m.processor.Admin().RuleCreate(c.Request.Context(), form)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
c.JSON(http.StatusOK, apiRule)
}
func validateCreateRule(form *apimodel.InstanceRuleCreateRequest) error {
if form.Text == "" {
return errors.New("Instance rule text is empty")
}
return nil
}

View file

@ -0,0 +1,107 @@
// 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"
)
// RuleDELETEHandler swagger:operation DELETE /api/v1/admin/instance/rules{id} ruleDelete
//
// Delete an existing instance rule.
//
// ---
// tags:
// - admin
//
// consumes:
// - multipart/form-data
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// in: formData
// description: >-
// The id of the rule to delete.
// type: path
// required: true
//
// security:
// - OAuth2 Bearer:
// - admin
//
// responses:
// '200':
// description: The deleted instance rule.
// schema:
// "$ref": "#/definitions/instanceRule"
// '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) RuleDELETEHandler(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 _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
ruleID := c.Param(IDKey)
if ruleID == "" {
err := errors.New("no rule id specified")
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
apiRule, errWithCode := m.processor.Admin().RuleDelete(c.Request.Context(), ruleID)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
c.JSON(http.StatusOK, apiRule)
}

View file

@ -0,0 +1,102 @@
// 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"
)
// RuleGETHandler swagger:operation GET /api/v1/admin/rules/{id} adminRuleGet
//
// View instance rule with the given id.
//
// ---
// tags:
// - admin
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// type: string
// description: The id of the rule.
// in: path
// required: true
//
// security:
// - OAuth2 Bearer:
// - admin
//
// responses:
// '200':
// name: rule
// description: The requested rule.
// schema:
// "$ref": "#/definitions/instanceRule"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) RuleGETHandler(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 _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
ruleID := c.Param(IDKey)
if ruleID == "" {
err := errors.New("no rule id specified")
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
rule, errWithCode := m.processor.Admin().RuleGet(c.Request.Context(), ruleID)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
c.JSON(http.StatusOK, rule)
}

View file

@ -0,0 +1,91 @@
// 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"
)
// rulesGETHandler swagger:operation GET /api/v1/admin/rules rules
//
// View instance rules, with IDs.
//
// The rules will be returned in order (sorted by Order ascending).
//
// ---
// tags:
// - admin
//
// produces:
// - application/json
//
// parameters:
//
// security:
// - OAuth2 Bearer:
// - admin
//
// responses:
// '200':
// description: An array with all the rules for the local instance.
// schema:
// type: array
// items:
// "$ref": "#/definitions/instanceRule"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) RulesGETHandler(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 _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
resp, errWithCode := m.processor.Admin().RulesGet(c.Request.Context())
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
c.JSON(http.StatusOK, resp)
}

View file

@ -0,0 +1,127 @@
// 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/oauth"
)
// RulePATCHHandler swagger:operation PATCH /api/v1/admin/instance/rules{id} ruleUpdate
//
// Update an existing instance rule.
//
// ---
// tags:
// - admin
//
// consumes:
// - multipart/form-data
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// in: formData
// description: >-
// The id of the rule to update.
// type: path
// required: true
// -
// name: text
// in: formData
// description: >-
// Text body for the updated instance rule, plaintext.
// type: string
// required: true
//
// security:
// - OAuth2 Bearer:
// - admin
//
// responses:
// '200':
// description: The updated instance rule.
// schema:
// "$ref": "#/definitions/instanceRule"
// '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) RulePATCHHandler(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 _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
ruleID := c.Param(IDKey)
if ruleID == "" {
err := errors.New("no rule id specified")
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
form := &apimodel.InstanceRuleCreateRequest{}
if err := c.ShouldBind(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
// reuses CreateRule validator
if err := validateCreateRule(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
apiRule, errWithCode := m.processor.Admin().RuleUpdate(c.Request.Context(), ruleID, form)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
c.JSON(http.StatusOK, apiRule)
}

View file

@ -28,6 +28,7 @@
InstanceInformationPathV1 = "/v1/instance"
InstanceInformationPathV2 = "/v2/instance"
InstancePeersPath = InstanceInformationPathV1 + "/peers"
InstanceRulesPath = InstanceInformationPathV1 + "/rules"
PeersFilterKey = "filter" // PeersFilterKey is used to provide filters to /api/v1/instance/peers
)
@ -47,4 +48,6 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H
attachHandler(http.MethodPatch, InstanceInformationPathV1, m.InstanceUpdatePATCHHandler)
attachHandler(http.MethodGet, InstancePeersPath, m.InstancePeersGETHandler)
attachHandler(http.MethodGet, InstanceRulesPath, m.InstanceRulesGETHandler)
}

View file

@ -160,7 +160,17 @@ func (suite *InstancePatchTestSuite) TestInstancePatch1() {
"name": "admin"
}
},
"max_toot_chars": 5000
"max_toot_chars": 5000,
"rules": [
{
"id": "01GP3AWY4CRDVRNZKW0TEAMB51",
"text": "Be gay"
},
{
"id": "01GP3DFY9XQ1TJMZT5BGAZPXX3",
"text": "Do crime"
}
]
}`, dst.String())
}
@ -264,7 +274,17 @@ func (suite *InstancePatchTestSuite) TestInstancePatch2() {
"name": "admin"
}
},
"max_toot_chars": 5000
"max_toot_chars": 5000,
"rules": [
{
"id": "01GP3AWY4CRDVRNZKW0TEAMB51",
"text": "Be gay"
},
{
"id": "01GP3DFY9XQ1TJMZT5BGAZPXX3",
"text": "Do crime"
}
]
}`, dst.String())
}
@ -368,7 +388,17 @@ func (suite *InstancePatchTestSuite) TestInstancePatch3() {
"name": "admin"
}
},
"max_toot_chars": 5000
"max_toot_chars": 5000,
"rules": [
{
"id": "01GP3AWY4CRDVRNZKW0TEAMB51",
"text": "Be gay"
},
{
"id": "01GP3DFY9XQ1TJMZT5BGAZPXX3",
"text": "Do crime"
}
]
}`, dst.String())
}
@ -523,7 +553,17 @@ func (suite *InstancePatchTestSuite) TestInstancePatch6() {
"name": "admin"
}
},
"max_toot_chars": 5000
"max_toot_chars": 5000,
"rules": [
{
"id": "01GP3AWY4CRDVRNZKW0TEAMB51",
"text": "Be gay"
},
{
"id": "01GP3DFY9XQ1TJMZT5BGAZPXX3",
"text": "Do crime"
}
]
}`, dst.String())
}
@ -651,7 +691,17 @@ func (suite *InstancePatchTestSuite) TestInstancePatch8() {
"name": "admin"
}
},
"max_toot_chars": 5000
"max_toot_chars": 5000,
"rules": [
{
"id": "01GP3AWY4CRDVRNZKW0TEAMB51",
"text": "Be gay"
},
{
"id": "01GP3DFY9XQ1TJMZT5BGAZPXX3",
"text": "Do crime"
}
]
}`, dst.String())
// extra bonus: check the v2 model thumbnail after the patch
@ -790,7 +840,17 @@ func (suite *InstancePatchTestSuite) TestInstancePatch9() {
"name": "admin"
}
},
"max_toot_chars": 5000
"max_toot_chars": 5000,
"rules": [
{
"id": "01GP3AWY4CRDVRNZKW0TEAMB51",
"text": "Be gay"
},
{
"id": "01GP3DFY9XQ1TJMZT5BGAZPXX3",
"text": "Do crime"
}
]
}`, dst.String())
}

View file

@ -0,0 +1,71 @@
// 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 instance
import (
"net/http"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
)
// instanceRulesGETHandler swagger:operation GET /api/v1/instance/rules rules
//
// View instance rules (public).
//
// The rules will be returned in order (sorted by Order ascending).
//
// ---
// tags:
// - instance
//
// produces:
// - application/json
//
// parameters:
//
// responses:
// '200':
// description: An array with all the rules for the local instance.
// schema:
// type: array
// items:
// "$ref": "#/definitions/instanceRule"
// '400':
// description: bad request
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) InstanceRulesGETHandler(c *gin.Context) {
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
resp, errWithCode := m.processor.InstanceGetRules(c.Request.Context())
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
c.JSON(http.StatusOK, resp)
}

View file

@ -51,17 +51,13 @@ func (suite *ReportCreateTestSuite) createReport(expectedHTTPStatus int, expecte
// create the request
ctx.Request = httptest.NewRequest(http.MethodPost, config.GetProtocol()+"://"+config.GetHost()+"/api/"+reports.BasePath, nil)
ctx.Request.Header.Set("accept", "application/json")
ruleIDs := make([]string, 0, len(form.RuleIDs))
for _, r := range form.RuleIDs {
ruleIDs = append(ruleIDs, strconv.Itoa(r))
}
ctx.Request.Form = url.Values{
"account_id": {form.AccountID},
"status_ids[]": form.StatusIDs,
"comment": {form.Comment},
"forward": {strconv.FormatBool(form.Forward)},
"category": {form.Category},
"rule_ids[]": ruleIDs,
"rule_ids[]": form.RuleIDs,
}
// trigger the handler

View file

@ -108,7 +108,10 @@ func (suite *ReportGetTestSuite) TestGetReport1() {
"status_ids": [
"01FVW7JHQFSFK166WWKR8CBA6M"
],
"rule_ids": [],
"rule_ids": [
"01GP3AWY4CRDVRNZKW0TEAMB51",
"01GP3DFY9XQ1TJMZT5BGAZPXX3"
],
"target_account": {
"id": "01F8MH5ZK5VRH73AKHQM6Y9VNX",
"username": "foss_satan",

View file

@ -133,7 +133,10 @@ func (suite *ReportsGetTestSuite) TestGetReports() {
"status_ids": [
"01FVW7JHQFSFK166WWKR8CBA6M"
],
"rule_ids": [],
"rule_ids": [
"01GP3AWY4CRDVRNZKW0TEAMB51",
"01GP3DFY9XQ1TJMZT5BGAZPXX3"
],
"target_account": {
"id": "01F8MH5ZK5VRH73AKHQM6Y9VNX",
"username": "foss_satan",
@ -220,7 +223,10 @@ func (suite *ReportsGetTestSuite) TestGetReports4() {
"status_ids": [
"01FVW7JHQFSFK166WWKR8CBA6M"
],
"rule_ids": [],
"rule_ids": [
"01GP3AWY4CRDVRNZKW0TEAMB51",
"01GP3DFY9XQ1TJMZT5BGAZPXX3"
],
"target_account": {
"id": "01F8MH5ZK5VRH73AKHQM6Y9VNX",
"username": "foss_satan",
@ -291,7 +297,10 @@ func (suite *ReportsGetTestSuite) TestGetReports6() {
"status_ids": [
"01FVW7JHQFSFK166WWKR8CBA6M"
],
"rule_ids": [],
"rule_ids": [
"01GP3AWY4CRDVRNZKW0TEAMB51",
"01GP3DFY9XQ1TJMZT5BGAZPXX3"
],
"target_account": {
"id": "01F8MH5ZK5VRH73AKHQM6Y9VNX",
"username": "foss_satan",
@ -346,7 +355,10 @@ func (suite *ReportsGetTestSuite) TestGetReports7() {
"status_ids": [
"01FVW7JHQFSFK166WWKR8CBA6M"
],
"rule_ids": [],
"rule_ids": [
"01GP3AWY4CRDVRNZKW0TEAMB51",
"01GP3DFY9XQ1TJMZT5BGAZPXX3"
],
"target_account": {
"id": "01F8MH5ZK5VRH73AKHQM6Y9VNX",
"username": "foss_satan",

View file

@ -117,9 +117,9 @@ type AdminReport struct {
// Array of statuses that were submitted along with this report.
// Will be empty if no status IDs were submitted with the report.
Statuses []*Status `json:"statuses"`
// Array of rule IDs that were submitted along with this report.
// NOT IMPLEMENTED, will always be empty array.
Rules []interface{} `json:"rule_ids"`
// Array of rules that were broken according to this report.
// Will be empty if no rule IDs were submitted with the report.
Rules []*InstanceRule `json:"rules"`
// If an action was taken, what comment was made by the admin on the taken action?
// Will be null if not set / no action yet taken.
// example: Account was suspended.
@ -189,3 +189,10 @@ type AdminSendTestEmailRequest struct {
// Email address to send the test email to.
Email string `form:"email" json:"email" xml:"email"`
}
type AdminInstanceRule struct {
ID string `json:"id"` // id of this item in the database
CreatedAt string `json:"created_at"` // when was item created
UpdatedAt string `json:"updated_at"` // when was item last updated
Text string `json:"text"` // text content of the rule
}

View file

@ -88,6 +88,8 @@ type InstanceV1 struct {
//
// example: 5000
MaxTootChars uint `json:"max_toot_chars"`
// An itemized list of rules for this instance.
Rules []InstanceRule `json:"rules"`
}
// InstanceV1URLs models instance-relevant URLs for client application consumption.

View file

@ -62,9 +62,8 @@ type InstanceV2 struct {
Registrations InstanceV2Registrations `json:"registrations"`
// Hints related to contacting a representative of the instance.
Contact InstanceV2Contact `json:"contact"`
// An itemized list of rules for this website.
// Currently not implemented (will always be empty array).
Rules []interface{} `json:"rules"`
// An itemized list of rules for this instance.
Rules []InstanceRule `json:"rules"`
}
// Usage data for this instance.

View file

@ -54,8 +54,8 @@ type Report struct {
StatusIDs []string `json:"status_ids"`
// Array of rule IDs that were submitted along with this report.
// Will be empty if no rule IDs were submitted.
// example: [1, 2]
RuleIDs []int `json:"rule_ids"`
// example: ["01GPBN5YDY6JKBWE44H7YQBDCQ","01GPBN65PDWSBPWVDD0SQCFFY3"]
RuleIDs []string `json:"rule_ids"`
// Account that was reported.
TargetAccount *Account `json:"target_account"`
}
@ -89,8 +89,7 @@ type ReportCreateRequest struct {
// in: formData
Category string `form:"category" json:"category" xml:"category"`
// IDs of rules on this instance which have been broken according to the reporter.
// This is currently not supported, provided only for API compatibility.
// example: [1, 2, 3]
// example: ["01GPBN5YDY6JKBWE44H7YQBDCQ","01GPBN65PDWSBPWVDD0SQCFFY3"]
// in: formData
RuleIDs []int `form:"rule_ids[]" json:"rule_ids" xml:"rule_ids"`
RuleIDs []string `form:"rule_ids[]" json:"rule_ids" xml:"rule_ids"`
}

View file

@ -0,0 +1,41 @@
// 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 model
// InstanceRule represents a single instance rule.
//
// swagger:model instanceRule
type InstanceRule struct {
ID string `json:"id"`
Text string `json:"text"`
}
// InstanceRuleCreateRequest represents a request to create a new instance rule, made through the admin API.
//
// swagger:model instanceRuleCreateRequest
type InstanceRuleCreateRequest struct {
Text string `form:"text" validation:"required"`
}
// InstanceRuleUpdateRequest represents a request to update the text of an instance rule, made through the admin API.
//
// swagger:model instanceRuleUpdateRequest
type InstanceRuleUpdateRequest struct {
ID string `form:"id"`
Text string `form:"text"`
}

View file

@ -72,6 +72,7 @@ type DBService struct {
db.Notification
db.Relationship
db.Report
db.Rule
db.Search
db.Session
db.Status
@ -216,6 +217,10 @@ func NewBunDBService(ctx context.Context, state *state.State) (db.DB, error) {
db: db,
state: state,
},
Rule: &ruleDB{
db: db,
state: state,
},
Search: &searchDB{
db: db,
state: state,

View file

@ -51,6 +51,7 @@ type BunDBStandardTestSuite struct {
testListEntries map[string]*gtsmodel.ListEntry
testAccountNotes map[string]*gtsmodel.AccountNote
testMarkers map[string]*gtsmodel.Marker
testRules map[string]*gtsmodel.Rule
}
func (suite *BunDBStandardTestSuite) SetupSuite() {
@ -72,6 +73,7 @@ func (suite *BunDBStandardTestSuite) SetupSuite() {
suite.testListEntries = testrig.NewTestListEntries()
suite.testAccountNotes = testrig.NewTestAccountNotes()
suite.testMarkers = testrig.NewTestMarkers()
suite.testRules = testrig.NewTestRules()
}
func (suite *BunDBStandardTestSuite) SetupTest() {

View file

@ -151,6 +151,16 @@ func (i *instanceDB) getInstance(ctx context.Context, lookup string, dbQuery fun
return nil, err
}
if instance.Domain == config.GetHost() {
// also populate Rules
rules, err := i.state.DB.GetActiveRules(ctx)
if err != nil {
log.Error(ctx, err)
} else {
instance.Rules = rules
}
}
return &instance, nil
}, keyParts...)
if err != nil {

View file

@ -0,0 +1,47 @@
// 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"
gtsmodel "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 {
if _, err := tx.NewCreateTable().Model(&gtsmodel.Rule{}).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

@ -0,0 +1,53 @@
// 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"
"strings"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
)
func init() {
up := func(ctx context.Context, db *bun.DB) error {
if db.Dialect().Name() == dialect.SQLite { // sqlite does not have an array type
_, err := db.ExecContext(ctx, "ALTER TABLE ? ADD COLUMN ? VARCHAR", bun.Ident("reports"), bun.Ident("rules"))
if err != nil && !(strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "duplicate column name") || strings.Contains(err.Error(), "SQLSTATE 42701")) {
return err
}
} else {
_, err := db.ExecContext(ctx, "ALTER TABLE ? ADD COLUMN ? VARCHAR[]", bun.Ident("reports"), bun.Ident("rules"))
if err != nil && !(strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "duplicate column name") || strings.Contains(err.Error(), "SQLSTATE 42701")) {
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

@ -186,6 +186,19 @@ func (r *reportDB) PopulateReport(ctx context.Context, report *gtsmodel.Report)
}
}
if l := len(report.RuleIDs); l > 0 && l != len(report.Rules) {
// Report target rules not set, fetch from the database.
for _, v := range report.RuleIDs {
rule, err := r.state.DB.GetRuleByID(ctx, v)
if err != nil {
errs.Appendf("error populating report rules: %w", err)
} else {
report.Rules = append(report.Rules, rule)
}
}
}
if report.ActionTakenByAccountID != "" &&
report.ActionTakenByAccount == nil {
// Report action account is not set, fetch from the database.

149
internal/db/bundb/rule.go Normal file
View file

@ -0,0 +1,149 @@
// 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"
"time"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/uptrace/bun"
)
type ruleDB struct {
db *DB
state *state.State
}
func (r *ruleDB) GetRuleByID(ctx context.Context, id string) (*gtsmodel.Rule, error) {
var rule gtsmodel.Rule
q := r.db.
NewSelect().
Model(&rule).
Where("? = ?", bun.Ident("rule.id"), id)
if err := q.Scan(ctx); err != nil {
return nil, err
}
return &rule, nil
}
func (r *ruleDB) GetRulesByIDs(ctx context.Context, ids []string) ([]*gtsmodel.Rule, error) {
rules := make([]*gtsmodel.Rule, 0, len(ids))
for _, id := range ids {
// Attempt to fetch status from DB.
rule, err := r.GetRuleByID(ctx, id)
if err != nil {
log.Errorf(ctx, "error getting rule %q: %v", id, err)
continue
}
// Append status to return slice.
rules = append(rules, rule)
}
return rules, nil
}
func (r *ruleDB) GetActiveRules(ctx context.Context) ([]gtsmodel.Rule, error) {
rules := make([]gtsmodel.Rule, 0)
q := r.db.
NewSelect().
Model(&rules).
// Ignore deleted (ie., inactive) rules.
Where("? = ?", bun.Ident("rule.deleted"), false).
Order("rule.order ASC")
if err := q.Scan(ctx); err != nil {
return nil, err
}
return rules, nil
}
func (r *ruleDB) PutRule(ctx context.Context, rule *gtsmodel.Rule) error {
var lastRuleOrder uint
// Select highest existing rule order.
err := r.db.
NewSelect().
TableExpr("? AS ?", bun.Ident("rules"), bun.Ident("rule")).
Column("rule.order").
Order("rule.order DESC").
Limit(1).
Scan(ctx, &lastRuleOrder)
switch {
case errors.Is(err, db.ErrNoEntries):
// No rules set yet, index from 0.
rule.Order = util.Ptr(uint(0))
case err != nil:
// Real db error.
return err
default:
// No error means previous rule(s)
// existed. New rule order should
// be 1 higher than previous rule.
rule.Order = func() *uint {
o := lastRuleOrder + 1
return &o
}()
}
if _, err := r.db.
NewInsert().
Model(rule).
Exec(ctx); err != nil {
return err
}
// invalidate cached local instance response, so it gets updated with the new rules
r.state.Caches.GTS.Instance().Invalidate("Domain", config.GetHost())
return nil
}
func (r *ruleDB) UpdateRule(ctx context.Context, rule *gtsmodel.Rule) (*gtsmodel.Rule, error) {
// Update the rule's last-updated
rule.UpdatedAt = time.Now()
if _, err := r.db.
NewUpdate().
Model(rule).
WherePK().
Exec(ctx); err != nil {
return nil, err
}
// invalidate cached local instance response, so it gets updated with the new rules
r.state.Caches.GTS.Instance().Invalidate("Domain", config.GetHost())
return rule, nil
}

View file

@ -0,0 +1,122 @@
// 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"
"testing"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
)
type RuleTestSuite struct {
BunDBStandardTestSuite
}
func (suite *RuleTestSuite) TestPutRuleWithExisting() {
r := &gtsmodel.Rule{
ID: id.NewULID(),
Text: "Pee pee poo poo",
}
if err := suite.state.DB.PutRule(context.Background(), r); err != nil {
suite.FailNow(err.Error())
}
suite.Equal(uint(len(suite.testRules)), *r.Order)
}
func (suite *RuleTestSuite) TestPutRuleNoExisting() {
var (
ctx = context.Background()
whereAny = []db.Where{{Key: "id", Value: "", Not: true}}
)
// Wipe all existing rules from the DB.
if err := suite.state.DB.DeleteWhere(
ctx,
whereAny,
&[]*gtsmodel.Rule{},
); err != nil {
suite.FailNow(err.Error())
}
r := &gtsmodel.Rule{
ID: id.NewULID(),
Text: "Pee pee poo poo",
}
if err := suite.state.DB.PutRule(ctx, r); err != nil {
suite.FailNow(err.Error())
}
// New rule is now only rule.
suite.EqualValues(uint(0), *r.Order)
}
func (suite *RuleTestSuite) TestGetRuleByID() {
rule, err := suite.state.DB.GetRuleByID(
context.Background(),
suite.testRules["rule1"].ID,
)
if err != nil {
suite.FailNow(err.Error())
}
suite.NotNil(rule)
}
func (suite *RuleTestSuite) TestGetRulesByID() {
ruleIDs := make([]string, 0, len(suite.testRules))
for _, rule := range suite.testRules {
ruleIDs = append(ruleIDs, rule.ID)
}
rules, err := suite.state.DB.GetRulesByIDs(
context.Background(),
ruleIDs,
)
if err != nil {
suite.FailNow(err.Error())
}
suite.Len(rules, len(suite.testRules))
}
func (suite *RuleTestSuite) TestGetActiveRules() {
var activeRules int
for _, rule := range suite.testRules {
if !*rule.Deleted {
activeRules++
}
}
rules, err := suite.state.DB.GetActiveRules(context.Background())
if err != nil {
suite.FailNow(err.Error())
}
suite.Len(rules, activeRules)
}
func TestRuleTestSuite(t *testing.T) {
suite.Run(t, new(RuleTestSuite))
}

View file

@ -38,6 +38,7 @@ type DB interface {
Notification
Relationship
Report
Rule
Search
Session
Status

42
internal/db/rule.go Normal file
View file

@ -0,0 +1,42 @@
// 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 db
import (
"context"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
// Rule handles getting/creation/deletion/updating of instance rules.
type Rule interface {
// GetRuleByID gets one rule by its db id.
GetRuleByID(ctx context.Context, id string) (*gtsmodel.Rule, error)
// GetRulesByIDs gets multiple rules by their db idd.
GetRulesByIDs(ctx context.Context, ids []string) ([]*gtsmodel.Rule, error)
// GetRules gets all active (not deleted) rules.
GetActiveRules(ctx context.Context) ([]gtsmodel.Rule, error)
// PutRule puts the given rule in the database.
PutRule(ctx context.Context, rule *gtsmodel.Rule) error
// UpdateRule updates one rule by its db id.
UpdateRule(ctx context.Context, rule *gtsmodel.Rule) (*gtsmodel.Rule, error)
}

View file

@ -39,4 +39,5 @@ type Instance struct {
ContactAccount *Account `bun:"rel:belongs-to"` // account corresponding to contactAccountID
Reputation int64 `bun:",notnull,default:0"` // Reputation score of this instance
Version string `bun:",nullzero"` // Version of the software used on this instance
Rules []Rule `bun:"-"` // List of instance rules
}

View file

@ -37,6 +37,8 @@ type Report struct {
Comment string `bun:",nullzero"` // comment / explanation for this report, by the reporter
StatusIDs []string `bun:"statuses,array"` // database IDs of any statuses referenced by this report
Statuses []*Status `bun:"-"` // statuses corresponding to StatusIDs
RuleIDs []string `bun:"rules,array"` // database IDs of any rules referenced by this report
Rules []*Rule `bun:"-"` // rules corresponding to RuleIDs
Forwarded *bool `bun:",nullzero,notnull,default:false"` // flag to indicate report should be forwarded to remote instance
ActionTaken string `bun:",nullzero"` // string description of what action was taken in response to this report
ActionTakenAt time.Time `bun:"type:timestamptz,nullzero"` // time at which action was taken, if any

30
internal/gtsmodel/rule.go Normal file
View file

@ -0,0 +1,30 @@
// 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"
// Rule models an instance rule set by the admin
type Rule 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"` // when was item created
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
Text string `bun:",nullzero"` // text content of the rule
Order *uint `bun:",nullzero,notnull,unique"` // rule ordering, index from 0
Deleted *bool `bun:",nullzero,notnull,default:false"` // has this rule been deleted, still kept in database for reference in historic reports
}

View file

@ -0,0 +1,127 @@
// 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"
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"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
// RulesGet returns all rules stored on this instance.
func (p *Processor) RulesGet(
ctx context.Context,
) ([]*apimodel.AdminInstanceRule, gtserror.WithCode) {
rules, err := p.state.DB.GetActiveRules(ctx)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
apiRules := make([]*apimodel.AdminInstanceRule, len(rules))
for i := range rules {
apiRules[i] = p.tc.InstanceRuleToAdminAPIRule(&rules[i])
}
return apiRules, nil
}
// RuleGet returns one rule, with the given ID.
func (p *Processor) RuleGet(ctx context.Context, id string) (*apimodel.AdminInstanceRule, gtserror.WithCode) {
rule, err := p.state.DB.GetRuleByID(ctx, id)
if err != nil {
if err == db.ErrNoEntries {
return nil, gtserror.NewErrorNotFound(err)
}
return nil, gtserror.NewErrorInternalError(err)
}
return p.tc.InstanceRuleToAdminAPIRule(rule), nil
}
// RuleCreate adds a new rule to the instance.
func (p *Processor) RuleCreate(ctx context.Context, form *apimodel.InstanceRuleCreateRequest) (*apimodel.AdminInstanceRule, gtserror.WithCode) {
ruleID, err := id.NewRandomULID()
if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error creating id for new instance rule: %s", err), "error creating rule ID")
}
rule := &gtsmodel.Rule{
ID: ruleID,
Text: form.Text,
}
if err = p.state.DB.PutRule(ctx, rule); err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
return p.tc.InstanceRuleToAdminAPIRule(rule), nil
}
// RuleUpdate updates text for an existing rule.
func (p *Processor) RuleUpdate(ctx context.Context, id string, form *apimodel.InstanceRuleCreateRequest) (*apimodel.AdminInstanceRule, gtserror.WithCode) {
rule, err := p.state.DB.GetRuleByID(ctx, id)
if err != nil {
if errors.Is(err, db.ErrNoEntries) {
err = fmt.Errorf("RuleUpdate: no rule with id %s found in the db", id)
return nil, gtserror.NewErrorNotFound(err)
}
err := fmt.Errorf("RuleUpdate: db error: %s", err)
return nil, gtserror.NewErrorInternalError(err)
}
rule.Text = form.Text
updatedRule, err := p.state.DB.UpdateRule(ctx, rule)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
return p.tc.InstanceRuleToAdminAPIRule(updatedRule), nil
}
// RuleDelete deletes an existing rule.
func (p *Processor) RuleDelete(ctx context.Context, id string) (*apimodel.AdminInstanceRule, gtserror.WithCode) {
rule, err := p.state.DB.GetRuleByID(ctx, id)
if err != nil {
if errors.Is(err, db.ErrNoEntries) {
err = fmt.Errorf("RuleUpdate: no rule with id %s found in the db", id)
return nil, gtserror.NewErrorNotFound(err)
}
err := fmt.Errorf("RuleUpdate: db error: %s", err)
return nil, gtserror.NewErrorInternalError(err)
}
rule.Deleted = util.Ptr(true)
deletedRule, err := p.state.DB.UpdateRule(ctx, rule)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
return p.tc.InstanceRuleToAdminAPIRule(deletedRule), nil
}

View file

@ -136,6 +136,15 @@ func (p *Processor) InstancePeersGet(ctx context.Context, includeSuspended bool,
return domains, nil
}
func (p *Processor) InstanceGetRules(ctx context.Context) ([]apimodel.InstanceRule, gtserror.WithCode) {
i, err := p.getThisInstance(ctx)
if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("db error fetching instance: %s", err))
}
return p.tc.InstanceRulesToAPIRules(i.Rules), nil
}
func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSettingsUpdateRequest) (*apimodel.InstanceV1, gtserror.WithCode) {
// fetch the instance entry from the db for processing
host := config.GetHost()

View file

@ -64,6 +64,13 @@ func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form
}
}
// fetch rules by IDs given in the report form (noop if no rules given)
rules, err := p.state.DB.GetRulesByIDs(ctx, form.RuleIDs)
if err != nil {
err = fmt.Errorf("db error fetching report target rules: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
reportID := id.NewULID()
report := &gtsmodel.Report{
ID: reportID,
@ -75,6 +82,8 @@ func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form
Comment: form.Comment,
StatusIDs: form.StatusIDs,
Statuses: statuses,
RuleIDs: form.RuleIDs,
Rules: rules,
Forwarded: &form.Forward,
}

View file

@ -83,6 +83,10 @@ type TypeConverter interface {
InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Instance) (*apimodel.InstanceV1, error)
// InstanceToAPIV2Instance converts a gts instance into its api equivalent for serving at /api/v2/instance
InstanceToAPIV2Instance(ctx context.Context, i *gtsmodel.Instance) (*apimodel.InstanceV2, error)
// InstanceRulesToAPIRules converts all local instance rules into their api equivalent for serving at /api/v1/instance/rules
InstanceRulesToAPIRules(r []gtsmodel.Rule) []apimodel.InstanceRule
// InstanceRuleToAdminAPIRule converts a local instance rule into its api equivalent for serving at /api/v1/admin/instance/rules/:id
InstanceRuleToAdminAPIRule(r *gtsmodel.Rule) *apimodel.AdminInstanceRule
// RelationshipToAPIRelationship converts a gts relationship into its api equivalent for serving in various places
RelationshipToAPIRelationship(ctx context.Context, r *gtsmodel.Relationship) (*apimodel.Relationship, error)
// NotificationToAPINotification converts a gts notification into a api notification

View file

@ -738,6 +738,32 @@ func (c *converter) VisToAPIVis(ctx context.Context, m gtsmodel.Visibility) apim
return ""
}
func (c *converter) InstanceRuleToAPIRule(r gtsmodel.Rule) apimodel.InstanceRule {
return apimodel.InstanceRule{
ID: r.ID,
Text: r.Text,
}
}
func (c *converter) InstanceRulesToAPIRules(r []gtsmodel.Rule) []apimodel.InstanceRule {
rules := make([]apimodel.InstanceRule, len(r))
for i, v := range r {
rules[i] = c.InstanceRuleToAPIRule(v)
}
return rules
}
func (c *converter) InstanceRuleToAdminAPIRule(r *gtsmodel.Rule) *apimodel.AdminInstanceRule {
return &apimodel.AdminInstanceRule{
ID: r.ID,
CreatedAt: util.FormatISO8601(r.CreatedAt),
UpdatedAt: util.FormatISO8601(r.UpdatedAt),
Text: r.Text,
}
}
func (c *converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Instance) (*apimodel.InstanceV1, error) {
instance := &apimodel.InstanceV1{
URI: i.URI,
@ -752,6 +778,7 @@ func (c *converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Ins
ApprovalRequired: config.GetAccountsApprovalRequired(),
InvitesEnabled: false, // todo: not supported yet
MaxTootChars: uint(config.GetStatusesMaxChars()),
Rules: c.InstanceRulesToAPIRules(i.Rules),
}
if config.GetInstanceInjectMastodonVersion() {
@ -854,7 +881,7 @@ func (c *converter) InstanceToAPIV2Instance(ctx context.Context, i *gtsmodel.Ins
Description: i.Description,
Usage: apimodel.InstanceV2Usage{}, // todo: not implemented
Languages: []string{}, // todo: not implemented
Rules: []interface{}{}, // todo: not implemented
Rules: c.InstanceRulesToAPIRules(i.Rules),
}
if config.GetInstanceInjectMastodonVersion() {
@ -1051,7 +1078,7 @@ func (c *converter) ReportToAPIReport(ctx context.Context, r *gtsmodel.Report) (
Comment: r.Comment,
Forwarded: *r.Forwarded,
StatusIDs: r.StatusIDs,
RuleIDs: []int{}, // todo: not supported yet
RuleIDs: r.RuleIDs,
}
if !r.ActionTakenAt.IsZero() {
@ -1144,6 +1171,20 @@ func (c *converter) ReportToAdminAPIReport(ctx context.Context, r *gtsmodel.Repo
statuses = append(statuses, status)
}
rules := make([]*apimodel.InstanceRule, 0, len(r.RuleIDs))
if len(r.RuleIDs) != 0 && len(r.Rules) == 0 {
r.Rules, err = c.db.GetRulesByIDs(ctx, r.RuleIDs)
if err != nil {
return nil, fmt.Errorf("ReportToAdminAPIReport: error getting rules from the db: %w", err)
}
}
for _, v := range r.Rules {
rules = append(rules, &apimodel.InstanceRule{
ID: v.ID,
Text: v.Text,
})
}
if ac := r.ActionTaken; ac != "" {
actionTakenComment = &ac
}
@ -1163,7 +1204,7 @@ func (c *converter) ReportToAdminAPIReport(ctx context.Context, r *gtsmodel.Repo
ActionTakenByAccount: actionTakenByAccount,
ActionTakenComment: actionTakenComment,
Statuses: statuses,
Rules: []interface{}{}, // not implemented
Rules: rules,
}, nil
}

View file

@ -603,6 +603,7 @@ func (suite *InternalToFrontendTestSuite) TestInstanceV1ToFrontend() {
b, err := json.MarshalIndent(instance, "", " ")
suite.NoError(err)
// FIXME: "rules" is empty from the database, because it's not fetched through db.GetInstance
suite.Equal(`{
"uri": "http://localhost:8080",
"account_domain": "localhost:8080",
@ -689,7 +690,8 @@ func (suite *InternalToFrontendTestSuite) TestInstanceV1ToFrontend() {
"name": "admin"
}
},
"max_toot_chars": 5000
"max_toot_chars": 5000,
"rules": []
}`, string(b))
}
@ -887,7 +889,10 @@ func (suite *InternalToFrontendTestSuite) TestReportToFrontend1() {
"status_ids": [
"01FVW7JHQFSFK166WWKR8CBA6M"
],
"rule_ids": [],
"rule_ids": [
"01GP3AWY4CRDVRNZKW0TEAMB51",
"01GP3DFY9XQ1TJMZT5BGAZPXX3"
],
"target_account": {
"id": "01F8MH5ZK5VRH73AKHQM6Y9VNX",
"username": "foss_satan",
@ -1177,7 +1182,7 @@ func (suite *InternalToFrontendTestSuite) TestAdminReportToFrontend1() {
"created_by_application_id": "01F8MGXQRHYF5QPMTMXP78QC2F"
},
"statuses": [],
"rule_ids": [],
"rules": [],
"action_taken_comment": "user was warned not to be a turtle anymore"
}`, string(b))
}
@ -1380,7 +1385,16 @@ func (suite *InternalToFrontendTestSuite) TestAdminReportToFrontend2() {
"poll": null
}
],
"rule_ids": [],
"rules": [
{
"id": "01GP3AWY4CRDVRNZKW0TEAMB51",
"text": "Be gay"
},
{
"id": "01GP3DFY9XQ1TJMZT5BGAZPXX3",
"text": "Do crime"
}
],
"action_taken_comment": null
}`, string(b))
}
@ -1603,7 +1617,7 @@ func (suite *InternalToFrontendTestSuite) TestAdminReportToFrontendSuspendedLoca
"created_by_application_id": "01F8MGXQRHYF5QPMTMXP78QC2F"
},
"statuses": [],
"rule_ids": [],
"rules": [],
"action_taken_comment": "user was warned not to be a turtle anymore"
}`, string(b))
}

View file

@ -61,6 +61,7 @@
&gtsmodel.EmojiCategory{},
&gtsmodel.Tombstone{},
&gtsmodel.Report{},
&gtsmodel.Rule{},
&gtsmodel.AccountNote{},
}
@ -160,6 +161,12 @@ func StandardDBSetup(db db.DB, accounts map[string]*gtsmodel.Account) {
}
}
for _, v := range NewTestRules() {
if err := db.Put(ctx, v); err != nil {
log.Panic(nil, err)
}
}
for _, v := range NewTestDomainBlocks() {
if err := db.Put(ctx, v); err != nil {
log.Panic(nil, err)

View file

@ -2021,6 +2021,7 @@ func NewTestReports() map[string]*gtsmodel.Report {
Comment: "dark souls sucks, please yeet this nerd",
StatusIDs: []string{"01FVW7JHQFSFK166WWKR8CBA6M"},
Forwarded: util.Ptr(true),
RuleIDs: []string{"01GP3AWY4CRDVRNZKW0TEAMB51", "01GP3DFY9XQ1TJMZT5BGAZPXX3"},
},
"remote_account_1_report_local_account_2": {
ID: "01GP3DFY9XQ1TJMZT5BGAZPXX7",
@ -2031,6 +2032,7 @@ func NewTestReports() map[string]*gtsmodel.Report {
TargetAccountID: "01F8MH5NBDF2MV7CTC4Q5128HF",
Comment: "this is a turtle, not a person, therefore should not be a poster",
StatusIDs: []string{},
RuleIDs: []string{},
Forwarded: util.Ptr(true),
ActionTaken: "user was warned not to be a turtle anymore",
ActionTakenAt: TimeMustParse("2022-05-15T17:01:56+02:00"),
@ -2039,6 +2041,35 @@ func NewTestReports() map[string]*gtsmodel.Report {
}
}
func NewTestRules() map[string]*gtsmodel.Rule {
return map[string]*gtsmodel.Rule{
"rule1": {
ID: "01GP3AWY4CRDVRNZKW0TEAMB51",
CreatedAt: TimeMustParse("2022-05-14T12:20:03+02:00"),
UpdatedAt: TimeMustParse("2022-05-14T12:20:03+02:00"),
Text: "Be gay",
Deleted: util.Ptr(false),
Order: util.Ptr(uint(0)),
},
"deleted_rule": {
ID: "01GP3DFY9XQ1TJMZT5BGAZPXX2",
CreatedAt: TimeMustParse("2022-05-15T16:20:12+02:00"),
UpdatedAt: TimeMustParse("2022-05-15T16:20:12+02:00"),
Text: "Deleted",
Deleted: util.Ptr(true),
Order: util.Ptr(uint(1)),
},
"rule2": {
ID: "01GP3DFY9XQ1TJMZT5BGAZPXX3",
CreatedAt: TimeMustParse("2022-05-15T16:20:12+02:00"),
UpdatedAt: TimeMustParse("2022-05-15T16:20:12+02:00"),
Text: "Do crime",
Deleted: util.Ptr(false),
Order: util.Ptr(uint(2)),
},
}
}
// ActivityWithSignature wraps a pub.Activity along with its signature headers, for testing.
type ActivityWithSignature struct {
Activity pub.Activity

View file

@ -542,6 +542,57 @@ label {
}
}
.instance-rules {
list-style-position: inside;
margin: 0;
padding: 0;
a.rule {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
color: $fg;
text-decoration: none;
background: $toot-bg;
padding: 1rem;
margin: 0.5rem 0;
border-radius: $br;
line-height: 2rem;
position: relative;
&:hover {
color: $fg-accent;
.edit-icon {
display: inline;
}
}
.edit-icon {
display: none;
font-size: 1rem;
line-height: 1.5rem;
}
li {
font-size: 1.75rem;
padding: 0;
margin: 0;
h2 {
margin: 0;
margin-top: 0 !important;
display: inline-block;
font-size: 1.5rem;
}
}
span {
color: $fg-reduced;
}
}
}
@media screen and (max-width: 30rem) {
.domain-blocklist .entry {
grid-template-columns: 1fr;

View file

@ -141,22 +141,29 @@ function DomainBlockForm({ defaultDomain, block = {}, baseUrl }) {
{...disabledForm}
/>
<MutationButton
label="Suspend"
result={addResult}
{...disabledForm}
/>
{
isExistingBlock &&
<div className="action-buttons row">
<MutationButton
type="button"
onClick={() => removeBlock(block.id)}
label="Remove"
result={removeResult}
className="button danger"
label="Suspend"
result={addResult}
showError={false}
{...disabledForm}
/>
}
{
isExistingBlock &&
<MutationButton
type="button"
onClick={() => removeBlock(block.id)}
label="Remove"
result={removeResult}
className="button danger"
showError={false}
/>
}
</div>
{addResult.error && <Error error={addResult.error} />}
{removeResult.error && <Error error={removeResult.error} />}
</form>
);

View file

@ -21,23 +21,23 @@
const React = require("react");
const query = require("../lib/query");
const query = require("../../lib/query");
const {
useTextInput,
useFileInput
} = require("../lib/form");
} = require("../../lib/form");
const useFormSubmit = require("../lib/form/submit");
const useFormSubmit = require("../../lib/form/submit");
const {
TextInput,
TextArea,
FileInput
} = require("../components/form/inputs");
} = require("../../components/form/inputs");
const FormWithData = require("../lib/form/form-with-data");
const MutationButton = require("../components/form/mutation-button");
const FormWithData = require("../../lib/form/form-with-data");
const MutationButton = require("../../components/form/mutation-button");
module.exports = function AdminSettings() {
return (

View file

@ -0,0 +1,169 @@
/*
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/>.
*/
"use strict";
const React = require("react");
const { Switch, Route, Link, Redirect, useRoute } = require("wouter");
const query = require("../../lib/query");
const FormWithData = require("../../lib/form/form-with-data");
const { useBaseUrl } = require("../../lib/navigation/util");
const { useValue, useTextInput } = require("../../lib/form");
const useFormSubmit = require("../../lib/form/submit");
const { TextArea } = require("../../components/form/inputs");
const MutationButton = require("../../components/form/mutation-button");
module.exports = function InstanceRulesData({ baseUrl }) {
return (
<FormWithData
dataQuery={query.useInstanceRulesQuery}
DataForm={InstanceRules}
baseUrl={baseUrl}
/>
);
};
function InstanceRules({ baseUrl, data: rules }) {
return (
<Switch>
<Route path={`${baseUrl}/:ruleId`}>
<InstanceRuleDetail rules={rules} />
</Route>
<Route>
<div>
<h1>Instance Rules</h1>
<div>
<p>
The rules for your instance are listed on the about page, and can be selected when submitting reports.
</p>
</div>
<InstanceRuleList rules={rules} />
</div>
</Route>
</Switch>
);
}
function InstanceRuleList({ rules }) {
const newRule = useTextInput("text", {});
const [submitForm, result] = useFormSubmit({ newRule }, query.useAddInstanceRuleMutation(), {
onFinish: () => newRule.reset()
});
return (
<>
<form onSubmit={submitForm} className="new-rule">
<ol className="instance-rules">
{Object.values(rules).map((rule) => (
<InstanceRule key={rule.id} rule={rule} />
))}
</ol>
<TextArea
field={newRule}
label="New instance rule"
/>
<MutationButton label="Add rule" result={result} />
</form>
</>
);
}
function InstanceRule({ rule }) {
const baseUrl = useBaseUrl();
return (
<Link to={`${baseUrl}/${rule.id}`}>
<a className="rule">
<li>
<h2>{rule.text} <i className="fa fa-pencil edit-icon" /></h2>
</li>
<span>{new Date(rule.created_at).toLocaleString()}</span>
</a>
</Link>
);
}
function InstanceRuleDetail({ rules }) {
const baseUrl = useBaseUrl();
let [_match, params] = useRoute(`${baseUrl}/:ruleId`);
if (params?.ruleId == undefined || rules[params.ruleId] == undefined) {
return <Redirect to={baseUrl} />;
} else {
return (
<>
<Link to={baseUrl}><a>&lt; go back</a></Link>
<InstanceRuleForm rule={rules[params.ruleId]} />
</>
);
}
}
function InstanceRuleForm({ rule }) {
const baseUrl = useBaseUrl();
const form = {
id: useValue("id", rule.id),
rule: useTextInput("text", { defaultValue: rule.text })
};
const [submitForm, result] = useFormSubmit(form, query.useUpdateInstanceRuleMutation());
const [deleteRule, deleteResult] = query.useDeleteInstanceRuleMutation({ fixedCacheKey: rule.id });
if (result.isSuccess || deleteResult.isSuccess) {
return (
<Redirect to={baseUrl} />
);
}
return (
<div className="rule-detail">
<form onSubmit={submitForm}>
<TextArea
field={form.rule}
/>
<div className="action-buttons row">
<MutationButton
label="Save"
showError={false}
result={result}
disabled={!form.rule.hasChanged()}
/>
<MutationButton
type="button"
onClick={() => deleteRule(rule.id)}
label="Delete"
className="button danger"
showError={false}
result={deleteResult}
/>
</div>
{result.error && <Error error={result.error} />}
{deleteResult.error && <Error error={deleteResult.error} />}
</form>
</div>
);
}

View file

@ -60,7 +60,10 @@ const { Sidebar, ViewRouter } = createNavigation("/settings", [
Item("Local", { icon: "fa-home", wildcard: true }, require("./admin/emoji/local")),
Item("Remote", { icon: "fa-cloud" }, require("./admin/emoji/remote"))
]),
Item("Settings", { icon: "fa-sliders" }, require("./admin/settings"))
Menu("Settings", { icon: "fa-sliders" }, [
Item("Settings", { icon: "fa-sliders", url: "" }, require("./admin/settings")),
Item("Rules", { icon: "fa-dot-circle-o", wildcard: true }, require("./admin/settings/rules"))
])
])
]);

View file

@ -22,7 +22,8 @@
const {
replaceCacheOnMutation,
removeFromCacheOnMutation,
domainListToObject
domainListToObject,
idListToObject
} = require("../lib");
const base = require("../base");
@ -104,6 +105,51 @@ const endpoints = (build) => ({
return res.accounts ?? [];
}
}),
instanceRules: build.query({
query: () => ({
url: `/api/v1/admin/instance/rules`
}),
transformResponse: idListToObject
}),
addInstanceRule: build.mutation({
query: (formData) => ({
method: "POST",
url: `/api/v1/admin/instance/rules`,
asForm: true,
body: formData,
discardEmpty: true
}),
transformResponse: (data) => {
return {
[data.id]: data
};
},
...replaceCacheOnMutation("instanceRules")
}),
updateInstanceRule: build.mutation({
query: ({ id, ...edit }) => ({
method: "PATCH",
url: `/api/v1/admin/instance/rules/${id}`,
asForm: true,
body: edit,
discardEmpty: true
}),
transformResponse: (data) => {
return {
[data.id]: data
};
},
...replaceCacheOnMutation("instanceRules")
}),
deleteInstanceRule: build.mutation({
query: (id) => ({
method: "DELETE",
url: `/api/v1/admin/instance/rules/${id}`
}),
...removeFromCacheOnMutation("instanceRules", {
findKey: (_draft, rule) => rule.id
})
}),
...require("./import-export")(build),
...require("./custom-emoji")(build),
...require("./reports")(build)

View file

@ -59,7 +59,7 @@ function instanceBasedQuery(args, api, extraOptions) {
module.exports = createApi({
reducerPath: "api",
baseQuery: instanceBasedQuery,
tagTypes: ["Auth", "Emoji", "Reports", "Account"],
tagTypes: ["Auth", "Emoji", "Reports", "Account", "InstanceRules"],
endpoints: (build) => ({
instance: build.query({
query: () => ({

View file

@ -37,6 +37,13 @@ module.exports = {
(_) => Object.fromEntries(_)
]);
},
idListToObject: (data) => {
// Turn flat Array into Object keyed by entry id field
return syncpipe(data, [
(_) => _.map((entry) => [entry.id, entry]),
(_) => Object.fromEntries(_)
]);
},
replaceCacheOnMutation: makeCacheMutation((draft, newData) => {
Object.assign(draft, newData);
}),

View file

@ -26,7 +26,7 @@
</div>
<div>
<h2>Admin Contact</h2>
<h2 id="contact">Admin Contact</h2>
{{if .instance.ContactAccount}}
<a href="{{.instance.ContactAccount.URL}}" class="account-card">
<img class="avatar" src="{{.instance.ContactAccount.Avatar}}" alt="" />
@ -42,7 +42,16 @@
</div>
<div>
<h2>Features</h2>
<h2 id="rules">Rules</h2>
<ol>
{{range .instance.Rules}}
<li>{{.Text}}</li>
{{end}}
</ol>
</div>
<div>
<h2 id="features">Features</h2>
<ul>
<li>
Registration is
@ -68,8 +77,9 @@
</li>
</ul>
</div>
<div>
<h2>Moderated servers</h2>
<h2 id="moderated-servers">Moderated servers</h2>
<p>
ActivityPub instances exchange (federate) data with other servers, including accounts and toots.
This can be prevented for specific domains by suspending them. None of their content is stored,
@ -83,12 +93,12 @@
</div>
<div>
<h2>Instance Statistics</h2>
<ul>
<li>Users: <span class="count">{{.instance.Stats.user_count}}</span></li>
<li>Posts: <span class="count">{{.instance.Stats.status_count}}</span></li>
<li>Federates with: <span class="count">{{.instance.Stats.domain_count}}</span> instances</li>
</ul>
<h2 id="stats">Instance Statistics</h2>
<ul>
<li>Users: <span class="count">{{.instance.Stats.user_count}}</span></li>
<li>Posts: <span class="count">{{.instance.Stats.status_count}}</span></li>
<li>Federates with: <span class="count">{{.instance.Stats.domain_count}}</span> instances</li>
</ul>
</div>
</section>
</main>