From 92de8fb396265d057f18aab4de0bc8aff4b90188 Mon Sep 17 00:00:00 2001 From: f0x52 Date: Sat, 19 Aug 2023 14:33:15 +0200 Subject: [PATCH] [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 --- docs/api/swagger.yaml | 334 ++++++++++++++++-- internal/api/client/admin/admin.go | 41 ++- internal/api/client/admin/reportsget_test.go | 35 +- internal/api/client/admin/rulecreate.go | 120 +++++++ internal/api/client/admin/ruledelete.go | 107 ++++++ internal/api/client/admin/ruleget.go | 102 ++++++ internal/api/client/admin/rulesget.go | 91 +++++ internal/api/client/admin/ruleupdate.go | 127 +++++++ internal/api/client/instance/instance.go | 3 + .../api/client/instance/instancepatch_test.go | 72 +++- .../api/client/instance/instancerulesget.go | 71 ++++ .../api/client/reports/reportcreate_test.go | 6 +- internal/api/client/reports/reportget_test.go | 5 +- .../api/client/reports/reportsget_test.go | 20 +- internal/api/model/admin.go | 13 +- internal/api/model/instancev1.go | 2 + internal/api/model/instancev2.go | 5 +- internal/api/model/report.go | 9 +- internal/api/model/rule.go | 41 +++ internal/db/bundb/bundb.go | 5 + internal/db/bundb/bundb_test.go | 2 + internal/db/bundb/instance.go | 10 + .../migrations/20230815164500_rules_model.go | 47 +++ .../20230817174700_add_report_rule_ids.go | 53 +++ internal/db/bundb/report.go | 13 + internal/db/bundb/rule.go | 149 ++++++++ internal/db/bundb/rule_test.go | 122 +++++++ internal/db/db.go | 1 + internal/db/rule.go | 42 +++ internal/gtsmodel/instance.go | 1 + internal/gtsmodel/report.go | 2 + internal/gtsmodel/rule.go | 30 ++ internal/processing/admin/rule.go | 127 +++++++ internal/processing/instance.go | 9 + internal/processing/report/create.go | 9 + internal/typeutils/converter.go | 4 + internal/typeutils/internaltofrontend.go | 47 ++- internal/typeutils/internaltofrontend_test.go | 24 +- testrig/db.go | 7 + testrig/testmodels.go | 31 ++ web/source/css/base.css | 51 +++ .../settings/admin/federation/detail.js | 35 +- .../admin/{settings.js => settings/index.jsx} | 12 +- web/source/settings/admin/settings/rules.jsx | 169 +++++++++ web/source/settings/index.js | 5 +- web/source/settings/lib/query/admin/index.js | 48 ++- web/source/settings/lib/query/base.js | 2 +- web/source/settings/lib/query/lib.js | 7 + web/template/about.tmpl | 28 +- 49 files changed, 2189 insertions(+), 107 deletions(-) create mode 100644 internal/api/client/admin/rulecreate.go create mode 100644 internal/api/client/admin/ruledelete.go create mode 100644 internal/api/client/admin/ruleget.go create mode 100644 internal/api/client/admin/rulesget.go create mode 100644 internal/api/client/admin/ruleupdate.go create mode 100644 internal/api/client/instance/instancerulesget.go create mode 100644 internal/api/model/rule.go create mode 100644 internal/db/bundb/migrations/20230815164500_rules_model.go create mode 100644 internal/db/bundb/migrations/20230817174700_add_report_rule_ids.go create mode 100644 internal/db/bundb/rule.go create mode 100644 internal/db/bundb/rule_test.go create mode 100644 internal/db/rule.go create mode 100644 internal/gtsmodel/rule.go create mode 100644 internal/processing/admin/rule.go rename web/source/settings/admin/{settings.js => settings/index.jsx} (91%) create mode 100644 web/source/settings/admin/settings/rules.jsx diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index da25d29c5..a717139cf 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -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 diff --git a/internal/api/client/admin/admin.go b/internal/api/client/admin/admin.go index a6c825b2b..ce6604c29 100644 --- a/internal/api/client/admin/admin.go +++ b/internal/api/client/admin/admin.go @@ -25,22 +25,24 @@ import ( ) 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) } diff --git a/internal/api/client/admin/reportsget_test.go b/internal/api/client/admin/reportsget_test.go index 943e9711a..4c714a9e0 100644 --- a/internal/api/client/admin/reportsget_test.go +++ b/internal/api/client/admin/reportsget_test.go @@ -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)) diff --git a/internal/api/client/admin/rulecreate.go b/internal/api/client/admin/rulecreate.go new file mode 100644 index 000000000..7792233f6 --- /dev/null +++ b/internal/api/client/admin/rulecreate.go @@ -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 . + +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 +} diff --git a/internal/api/client/admin/ruledelete.go b/internal/api/client/admin/ruledelete.go new file mode 100644 index 000000000..7281ed62e --- /dev/null +++ b/internal/api/client/admin/ruledelete.go @@ -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 . + +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) +} diff --git a/internal/api/client/admin/ruleget.go b/internal/api/client/admin/ruleget.go new file mode 100644 index 000000000..444820a3f --- /dev/null +++ b/internal/api/client/admin/ruleget.go @@ -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 . + +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) +} diff --git a/internal/api/client/admin/rulesget.go b/internal/api/client/admin/rulesget.go new file mode 100644 index 000000000..56f83866f --- /dev/null +++ b/internal/api/client/admin/rulesget.go @@ -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 . + +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) +} diff --git a/internal/api/client/admin/ruleupdate.go b/internal/api/client/admin/ruleupdate.go new file mode 100644 index 000000000..82ed41190 --- /dev/null +++ b/internal/api/client/admin/ruleupdate.go @@ -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 . + +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) +} diff --git a/internal/api/client/instance/instance.go b/internal/api/client/instance/instance.go index 8c58b62aa..82f6a4714 100644 --- a/internal/api/client/instance/instance.go +++ b/internal/api/client/instance/instance.go @@ -28,6 +28,7 @@ const ( 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) } diff --git a/internal/api/client/instance/instancepatch_test.go b/internal/api/client/instance/instancepatch_test.go index 11382f83a..a402f8347 100644 --- a/internal/api/client/instance/instancepatch_test.go +++ b/internal/api/client/instance/instancepatch_test.go @@ -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()) } diff --git a/internal/api/client/instance/instancerulesget.go b/internal/api/client/instance/instancerulesget.go new file mode 100644 index 000000000..5cc99ba41 --- /dev/null +++ b/internal/api/client/instance/instancerulesget.go @@ -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 . + +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) +} diff --git a/internal/api/client/reports/reportcreate_test.go b/internal/api/client/reports/reportcreate_test.go index e17695cb9..35dc3d015 100644 --- a/internal/api/client/reports/reportcreate_test.go +++ b/internal/api/client/reports/reportcreate_test.go @@ -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 diff --git a/internal/api/client/reports/reportget_test.go b/internal/api/client/reports/reportget_test.go index e29836b6a..1bdb7557c 100644 --- a/internal/api/client/reports/reportget_test.go +++ b/internal/api/client/reports/reportget_test.go @@ -108,7 +108,10 @@ func (suite *ReportGetTestSuite) TestGetReport1() { "status_ids": [ "01FVW7JHQFSFK166WWKR8CBA6M" ], - "rule_ids": [], + "rule_ids": [ + "01GP3AWY4CRDVRNZKW0TEAMB51", + "01GP3DFY9XQ1TJMZT5BGAZPXX3" + ], "target_account": { "id": "01F8MH5ZK5VRH73AKHQM6Y9VNX", "username": "foss_satan", diff --git a/internal/api/client/reports/reportsget_test.go b/internal/api/client/reports/reportsget_test.go index d220dc94d..e58a622db 100644 --- a/internal/api/client/reports/reportsget_test.go +++ b/internal/api/client/reports/reportsget_test.go @@ -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", diff --git a/internal/api/model/admin.go b/internal/api/model/admin.go index cc449ab82..860cb8926 100644 --- a/internal/api/model/admin.go +++ b/internal/api/model/admin.go @@ -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 +} diff --git a/internal/api/model/instancev1.go b/internal/api/model/instancev1.go index 19682c1f1..3b3d215b0 100644 --- a/internal/api/model/instancev1.go +++ b/internal/api/model/instancev1.go @@ -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. diff --git a/internal/api/model/instancev2.go b/internal/api/model/instancev2.go index 25d9c790d..3099b36c4 100644 --- a/internal/api/model/instancev2.go +++ b/internal/api/model/instancev2.go @@ -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. diff --git a/internal/api/model/report.go b/internal/api/model/report.go index eb68e7911..b9b8c77d2 100644 --- a/internal/api/model/report.go +++ b/internal/api/model/report.go @@ -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"` } diff --git a/internal/api/model/rule.go b/internal/api/model/rule.go new file mode 100644 index 000000000..f4caf7dd0 --- /dev/null +++ b/internal/api/model/rule.go @@ -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 . + +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"` +} diff --git a/internal/db/bundb/bundb.go b/internal/db/bundb/bundb.go index ad9053e6e..e92234f81 100644 --- a/internal/db/bundb/bundb.go +++ b/internal/db/bundb/bundb.go @@ -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, diff --git a/internal/db/bundb/bundb_test.go b/internal/db/bundb/bundb_test.go index 0cdbb5cce..f3640cf59 100644 --- a/internal/db/bundb/bundb_test.go +++ b/internal/db/bundb/bundb_test.go @@ -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() { diff --git a/internal/db/bundb/instance.go b/internal/db/bundb/instance.go index 7f0e92634..6fec3f2fe 100644 --- a/internal/db/bundb/instance.go +++ b/internal/db/bundb/instance.go @@ -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 { diff --git a/internal/db/bundb/migrations/20230815164500_rules_model.go b/internal/db/bundb/migrations/20230815164500_rules_model.go new file mode 100644 index 000000000..9b202ede9 --- /dev/null +++ b/internal/db/bundb/migrations/20230815164500_rules_model.go @@ -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 . + +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(>smodel.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) + } +} diff --git a/internal/db/bundb/migrations/20230817174700_add_report_rule_ids.go b/internal/db/bundb/migrations/20230817174700_add_report_rule_ids.go new file mode 100644 index 000000000..a66739e4c --- /dev/null +++ b/internal/db/bundb/migrations/20230817174700_add_report_rule_ids.go @@ -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 . + +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) + } +} diff --git a/internal/db/bundb/report.go b/internal/db/bundb/report.go index 7c1dd16e7..9e4ba5b29 100644 --- a/internal/db/bundb/report.go +++ b/internal/db/bundb/report.go @@ -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. diff --git a/internal/db/bundb/rule.go b/internal/db/bundb/rule.go new file mode 100644 index 000000000..79825923b --- /dev/null +++ b/internal/db/bundb/rule.go @@ -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 . + +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 +} diff --git a/internal/db/bundb/rule_test.go b/internal/db/bundb/rule_test.go new file mode 100644 index 000000000..822f92fca --- /dev/null +++ b/internal/db/bundb/rule_test.go @@ -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 . + +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 := >smodel.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 := >smodel.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)) +} diff --git a/internal/db/db.go b/internal/db/db.go index 567551c73..056d03e23 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -38,6 +38,7 @@ type DB interface { Notification Relationship Report + Rule Search Session Status diff --git a/internal/db/rule.go b/internal/db/rule.go new file mode 100644 index 000000000..651b8bced --- /dev/null +++ b/internal/db/rule.go @@ -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 . + +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) +} diff --git a/internal/gtsmodel/instance.go b/internal/gtsmodel/instance.go index 388f0f4ed..6d572f519 100644 --- a/internal/gtsmodel/instance.go +++ b/internal/gtsmodel/instance.go @@ -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 } diff --git a/internal/gtsmodel/report.go b/internal/gtsmodel/report.go index e5b942563..b332ec348 100644 --- a/internal/gtsmodel/report.go +++ b/internal/gtsmodel/report.go @@ -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 diff --git a/internal/gtsmodel/rule.go b/internal/gtsmodel/rule.go new file mode 100644 index 000000000..76fa6f7bd --- /dev/null +++ b/internal/gtsmodel/rule.go @@ -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 . + +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 +} diff --git a/internal/processing/admin/rule.go b/internal/processing/admin/rule.go new file mode 100644 index 000000000..40a2bdcf3 --- /dev/null +++ b/internal/processing/admin/rule.go @@ -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 . + +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 := >smodel.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 +} diff --git a/internal/processing/instance.go b/internal/processing/instance.go index edcfe5418..2faef7527 100644 --- a/internal/processing/instance.go +++ b/internal/processing/instance.go @@ -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() diff --git a/internal/processing/report/create.go b/internal/processing/report/create.go index a6cce8e80..48f9c1ee4 100644 --- a/internal/processing/report/create.go +++ b/internal/processing/report/create.go @@ -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 := >smodel.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, } diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go index 73992fc0e..774b68157 100644 --- a/internal/typeutils/converter.go +++ b/internal/typeutils/converter.go @@ -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 diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index ab04f6ccc..050997bda 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -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 } diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go index d99a31e25..9f72c6d2e 100644 --- a/internal/typeutils/internaltofrontend_test.go +++ b/internal/typeutils/internaltofrontend_test.go @@ -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)) } diff --git a/testrig/db.go b/testrig/db.go index 4d8dfefa5..57e94a4bf 100644 --- a/testrig/db.go +++ b/testrig/db.go @@ -61,6 +61,7 @@ var testModels = []interface{}{ >smodel.EmojiCategory{}, >smodel.Tombstone{}, >smodel.Report{}, + >smodel.Rule{}, >smodel.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) diff --git a/testrig/testmodels.go b/testrig/testmodels.go index f5f63f6fa..4f0768b45 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -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 diff --git a/web/source/css/base.css b/web/source/css/base.css index 87d2fcca7..5cd2cd047 100644 --- a/web/source/css/base.css +++ b/web/source/css/base.css @@ -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; diff --git a/web/source/settings/admin/federation/detail.js b/web/source/settings/admin/federation/detail.js index 344b9f9b6..a3bbfcac1 100644 --- a/web/source/settings/admin/federation/detail.js +++ b/web/source/settings/admin/federation/detail.js @@ -141,22 +141,29 @@ function DomainBlockForm({ defaultDomain, block = {}, baseUrl }) { {...disabledForm} /> - - - { - isExistingBlock && +
removeBlock(block.id)} - label="Remove" - result={removeResult} - className="button danger" + label="Suspend" + result={addResult} + showError={false} + {...disabledForm} /> - } + + { + isExistingBlock && + removeBlock(block.id)} + label="Remove" + result={removeResult} + className="button danger" + showError={false} + /> + } +
+ + {addResult.error && } + {removeResult.error && } ); diff --git a/web/source/settings/admin/settings.js b/web/source/settings/admin/settings/index.jsx similarity index 91% rename from web/source/settings/admin/settings.js rename to web/source/settings/admin/settings/index.jsx index ec986a6c4..dab476433 100644 --- a/web/source/settings/admin/settings.js +++ b/web/source/settings/admin/settings/index.jsx @@ -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 ( diff --git a/web/source/settings/admin/settings/rules.jsx b/web/source/settings/admin/settings/rules.jsx new file mode 100644 index 000000000..330bc07fd --- /dev/null +++ b/web/source/settings/admin/settings/rules.jsx @@ -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 . +*/ + +"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 ( + + ); +}; + +function InstanceRules({ baseUrl, data: rules }) { + return ( + + + + + +
+

Instance Rules

+
+

+ The rules for your instance are listed on the about page, and can be selected when submitting reports. +

+
+ +
+
+
+ ); +} + +function InstanceRuleList({ rules }) { + const newRule = useTextInput("text", {}); + + const [submitForm, result] = useFormSubmit({ newRule }, query.useAddInstanceRuleMutation(), { + onFinish: () => newRule.reset() + }); + + return ( + <> +
+
    + {Object.values(rules).map((rule) => ( + + ))} +
+