[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 example: 01FBVD42CQ3ZEEVMW180SBX03B
type: string type: string
x-go-name: ID x-go-name: ID
rule_ids: rules:
description: |- description: |-
Array of rule IDs that were submitted along with this report. Array of rules that were broken according to this report.
NOT IMPLEMENTED, will always be empty array. Will be empty if no rule IDs were submitted with the report.
items: {} items:
$ref: '#/definitions/instanceRule'
type: array type: array
x-go-name: Rules x-go-name: Rules
statuses: statuses:
@ -1274,6 +1275,36 @@ definitions:
type: object type: object
x-go-name: InstanceConfigurationStatuses x-go-name: InstanceConfigurationStatuses
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model 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: instanceV1:
properties: properties:
account_domain: account_domain:
@ -1330,6 +1361,12 @@ definitions:
description: New account registrations are enabled on this instance. description: New account registrations are enabled on this instance.
type: boolean type: boolean
x-go-name: Registrations 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: short_description:
description: |- description: |-
A shorter description of the instance. A shorter description of the instance.
@ -1453,10 +1490,9 @@ definitions:
registrations: registrations:
$ref: '#/definitions/instanceV2Registrations' $ref: '#/definitions/instanceV2Registrations'
rules: rules:
description: |- description: An itemized list of rules for this instance.
An itemized list of rules for this website. items:
Currently not implemented (will always be empty array). $ref: '#/definitions/instanceRule'
items: {}
type: array type: array
x-go-name: Rules x-go-name: Rules
source_url: source_url:
@ -1755,6 +1791,72 @@ definitions:
type: object type: object
x-go-name: MediaMeta x-go-name: MediaMeta
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model 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: nodeinfo:
description: 'See: https://nodeinfo.diaspora.software/schema.html' description: 'See: https://nodeinfo.diaspora.software/schema.html'
properties: properties:
@ -1971,11 +2073,10 @@ definitions:
Array of rule IDs that were submitted along with this report. Array of rule IDs that were submitted along with this report.
Will be empty if no rule IDs were submitted. Will be empty if no rule IDs were submitted.
example: example:
- 1 - 01GPBN5YDY6JKBWE44H7YQBDCQ
- 2 - 01GPBN65PDWSBPWVDD0SQCFFY3
items: items:
format: int64 type: string
type: integer
type: array type: array
x-go-name: RuleIDs x-go-name: RuleIDs
status_ids: status_ids:
@ -4036,6 +4137,118 @@ paths:
summary: Send a generic test email to a specified email address. summary: Send a generic test email to a specified email address.
tags: tags:
- admin - 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: /api/v1/admin/media_cleanup:
post: post:
consumes: consumes:
@ -4251,6 +4464,67 @@ paths:
summary: Mark a report as resolved. summary: Mark a report as resolved.
tags: tags:
- admin - 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: /api/v1/apps:
post: post:
consumes: consumes:
@ -4750,6 +5024,30 @@ paths:
description: internal server error description: internal server error
tags: tags:
- instance - 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: /api/v1/lists:
get: get:
operationId: lists operationId: lists
@ -5505,17 +5803,13 @@ paths:
name: category name: category
type: string type: string
x-go-name: Category x-go-name: Category
- description: |- - description: IDs of rules on this instance which have been broken according to the reporter.
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: example:
- 1 - 01GPBN5YDY6JKBWE44H7YQBDCQ
- 2 - 01GPBN65PDWSBPWVDD0SQCFFY3
- 3
in: formData in: formData
items: items:
format: int64 type: string
type: integer
name: rule_ids name: rule_ids
type: array type: array
x-go-name: RuleIDs x-go-name: RuleIDs

View file

@ -41,6 +41,8 @@
ReportsResolvePath = ReportsPathWithID + "/resolve" ReportsResolvePath = ReportsPathWithID + "/resolve"
EmailPath = BasePath + "/email" EmailPath = BasePath + "/email"
EmailTestPath = EmailPath + "/test" EmailTestPath = EmailPath + "/test"
InstanceRulesPath = BasePath + "/instance/rules"
InstanceRulesPathWithID = InstanceRulesPath + "/:" + IDKey
IDKey = "id" IDKey = "id"
FilterQueryKey = "filter" FilterQueryKey = "filter"
@ -95,4 +97,11 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H
// email stuff // email stuff
attachHandler(http.MethodPost, EmailTestPath, m.EmailTestPOSTHandler) 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" "created_by_application_id": "01F8MGXQRHYF5QPMTMXP78QC2F"
}, },
"statuses": [], "statuses": [],
"rule_ids": [], "rules": [],
"action_taken_comment": "user was warned not to be a turtle anymore" "action_taken_comment": "user was warned not to be a turtle anymore"
}, },
{ {
@ -528,7 +528,16 @@ func (suite *ReportsGetTestSuite) TestReportsGetAll() {
"poll": null "poll": null
} }
], ],
"rule_ids": [], "rules": [
{
"id": "01GP3AWY4CRDVRNZKW0TEAMB51",
"text": "Be gay"
},
{
"id": "01GP3DFY9XQ1TJMZT5BGAZPXX3",
"text": "Do crime"
}
],
"action_taken_comment": null "action_taken_comment": null
} }
]`, string(b)) ]`, string(b))
@ -740,7 +749,16 @@ func (suite *ReportsGetTestSuite) TestReportsGetCreatedByAccount() {
"poll": null "poll": null
} }
], ],
"rule_ids": [], "rules": [
{
"id": "01GP3AWY4CRDVRNZKW0TEAMB51",
"text": "Be gay"
},
{
"id": "01GP3DFY9XQ1TJMZT5BGAZPXX3",
"text": "Do crime"
}
],
"action_taken_comment": null "action_taken_comment": null
} }
]`, string(b)) ]`, string(b))
@ -952,7 +970,16 @@ func (suite *ReportsGetTestSuite) TestReportsGetTargetAccount() {
"poll": null "poll": null
} }
], ],
"rule_ids": [], "rules": [
{
"id": "01GP3AWY4CRDVRNZKW0TEAMB51",
"text": "Be gay"
},
{
"id": "01GP3DFY9XQ1TJMZT5BGAZPXX3",
"text": "Do crime"
}
],
"action_taken_comment": null "action_taken_comment": null
} }
]`, string(b)) ]`, 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" InstanceInformationPathV1 = "/v1/instance"
InstanceInformationPathV2 = "/v2/instance" InstanceInformationPathV2 = "/v2/instance"
InstancePeersPath = InstanceInformationPathV1 + "/peers" InstancePeersPath = InstanceInformationPathV1 + "/peers"
InstanceRulesPath = InstanceInformationPathV1 + "/rules"
PeersFilterKey = "filter" // PeersFilterKey is used to provide filters to /api/v1/instance/peers 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.MethodPatch, InstanceInformationPathV1, m.InstanceUpdatePATCHHandler)
attachHandler(http.MethodGet, InstancePeersPath, m.InstancePeersGETHandler) attachHandler(http.MethodGet, InstancePeersPath, m.InstancePeersGETHandler)
attachHandler(http.MethodGet, InstanceRulesPath, m.InstanceRulesGETHandler)
} }

View file

@ -160,7 +160,17 @@ func (suite *InstancePatchTestSuite) TestInstancePatch1() {
"name": "admin" "name": "admin"
} }
}, },
"max_toot_chars": 5000 "max_toot_chars": 5000,
"rules": [
{
"id": "01GP3AWY4CRDVRNZKW0TEAMB51",
"text": "Be gay"
},
{
"id": "01GP3DFY9XQ1TJMZT5BGAZPXX3",
"text": "Do crime"
}
]
}`, dst.String()) }`, dst.String())
} }
@ -264,7 +274,17 @@ func (suite *InstancePatchTestSuite) TestInstancePatch2() {
"name": "admin" "name": "admin"
} }
}, },
"max_toot_chars": 5000 "max_toot_chars": 5000,
"rules": [
{
"id": "01GP3AWY4CRDVRNZKW0TEAMB51",
"text": "Be gay"
},
{
"id": "01GP3DFY9XQ1TJMZT5BGAZPXX3",
"text": "Do crime"
}
]
}`, dst.String()) }`, dst.String())
} }
@ -368,7 +388,17 @@ func (suite *InstancePatchTestSuite) TestInstancePatch3() {
"name": "admin" "name": "admin"
} }
}, },
"max_toot_chars": 5000 "max_toot_chars": 5000,
"rules": [
{
"id": "01GP3AWY4CRDVRNZKW0TEAMB51",
"text": "Be gay"
},
{
"id": "01GP3DFY9XQ1TJMZT5BGAZPXX3",
"text": "Do crime"
}
]
}`, dst.String()) }`, dst.String())
} }
@ -523,7 +553,17 @@ func (suite *InstancePatchTestSuite) TestInstancePatch6() {
"name": "admin" "name": "admin"
} }
}, },
"max_toot_chars": 5000 "max_toot_chars": 5000,
"rules": [
{
"id": "01GP3AWY4CRDVRNZKW0TEAMB51",
"text": "Be gay"
},
{
"id": "01GP3DFY9XQ1TJMZT5BGAZPXX3",
"text": "Do crime"
}
]
}`, dst.String()) }`, dst.String())
} }
@ -651,7 +691,17 @@ func (suite *InstancePatchTestSuite) TestInstancePatch8() {
"name": "admin" "name": "admin"
} }
}, },
"max_toot_chars": 5000 "max_toot_chars": 5000,
"rules": [
{
"id": "01GP3AWY4CRDVRNZKW0TEAMB51",
"text": "Be gay"
},
{
"id": "01GP3DFY9XQ1TJMZT5BGAZPXX3",
"text": "Do crime"
}
]
}`, dst.String()) }`, dst.String())
// extra bonus: check the v2 model thumbnail after the patch // extra bonus: check the v2 model thumbnail after the patch
@ -790,7 +840,17 @@ func (suite *InstancePatchTestSuite) TestInstancePatch9() {
"name": "admin" "name": "admin"
} }
}, },
"max_toot_chars": 5000 "max_toot_chars": 5000,
"rules": [
{
"id": "01GP3AWY4CRDVRNZKW0TEAMB51",
"text": "Be gay"
},
{
"id": "01GP3DFY9XQ1TJMZT5BGAZPXX3",
"text": "Do crime"
}
]
}`, dst.String()) }`, 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 // create the request
ctx.Request = httptest.NewRequest(http.MethodPost, config.GetProtocol()+"://"+config.GetHost()+"/api/"+reports.BasePath, nil) ctx.Request = httptest.NewRequest(http.MethodPost, config.GetProtocol()+"://"+config.GetHost()+"/api/"+reports.BasePath, nil)
ctx.Request.Header.Set("accept", "application/json") 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{ ctx.Request.Form = url.Values{
"account_id": {form.AccountID}, "account_id": {form.AccountID},
"status_ids[]": form.StatusIDs, "status_ids[]": form.StatusIDs,
"comment": {form.Comment}, "comment": {form.Comment},
"forward": {strconv.FormatBool(form.Forward)}, "forward": {strconv.FormatBool(form.Forward)},
"category": {form.Category}, "category": {form.Category},
"rule_ids[]": ruleIDs, "rule_ids[]": form.RuleIDs,
} }
// trigger the handler // trigger the handler

View file

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

View file

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

View file

@ -117,9 +117,9 @@ type AdminReport struct {
// Array of statuses that were submitted along with this report. // Array of statuses that were submitted along with this report.
// Will be empty if no status IDs were submitted with the report. // Will be empty if no status IDs were submitted with the report.
Statuses []*Status `json:"statuses"` Statuses []*Status `json:"statuses"`
// Array of rule IDs that were submitted along with this report. // Array of rules that were broken according to this report.
// NOT IMPLEMENTED, will always be empty array. // Will be empty if no rule IDs were submitted with the report.
Rules []interface{} `json:"rule_ids"` Rules []*InstanceRule `json:"rules"`
// If an action was taken, what comment was made by the admin on the taken action? // 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. // Will be null if not set / no action yet taken.
// example: Account was suspended. // example: Account was suspended.
@ -189,3 +189,10 @@ type AdminSendTestEmailRequest struct {
// Email address to send the test email to. // Email address to send the test email to.
Email string `form:"email" json:"email" xml:"email"` 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 // example: 5000
MaxTootChars uint `json:"max_toot_chars"` 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. // InstanceV1URLs models instance-relevant URLs for client application consumption.

View file

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

View file

@ -54,8 +54,8 @@ type Report struct {
StatusIDs []string `json:"status_ids"` StatusIDs []string `json:"status_ids"`
// Array of rule IDs that were submitted along with this report. // Array of rule IDs that were submitted along with this report.
// Will be empty if no rule IDs were submitted. // Will be empty if no rule IDs were submitted.
// example: [1, 2] // example: ["01GPBN5YDY6JKBWE44H7YQBDCQ","01GPBN65PDWSBPWVDD0SQCFFY3"]
RuleIDs []int `json:"rule_ids"` RuleIDs []string `json:"rule_ids"`
// Account that was reported. // Account that was reported.
TargetAccount *Account `json:"target_account"` TargetAccount *Account `json:"target_account"`
} }
@ -89,8 +89,7 @@ type ReportCreateRequest struct {
// in: formData // in: formData
Category string `form:"category" json:"category" xml:"category"` Category string `form:"category" json:"category" xml:"category"`
// IDs of rules on this instance which have been broken according to the reporter. // 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: ["01GPBN5YDY6JKBWE44H7YQBDCQ","01GPBN65PDWSBPWVDD0SQCFFY3"]
// example: [1, 2, 3]
// in: formData // 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.Notification
db.Relationship db.Relationship
db.Report db.Report
db.Rule
db.Search db.Search
db.Session db.Session
db.Status db.Status
@ -216,6 +217,10 @@ func NewBunDBService(ctx context.Context, state *state.State) (db.DB, error) {
db: db, db: db,
state: state, state: state,
}, },
Rule: &ruleDB{
db: db,
state: state,
},
Search: &searchDB{ Search: &searchDB{
db: db, db: db,
state: state, state: state,

View file

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

View file

@ -151,6 +151,16 @@ func (i *instanceDB) getInstance(ctx context.Context, lookup string, dbQuery fun
return nil, err 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 return &instance, nil
}, keyParts...) }, keyParts...)
if err != nil { 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 != "" && if report.ActionTakenByAccountID != "" &&
report.ActionTakenByAccount == nil { report.ActionTakenByAccount == nil {
// Report action account is not set, fetch from the database. // 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 Notification
Relationship Relationship
Report Report
Rule
Search Search
Session Session
Status 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 ContactAccount *Account `bun:"rel:belongs-to"` // account corresponding to contactAccountID
Reputation int64 `bun:",notnull,default:0"` // Reputation score of this instance Reputation int64 `bun:",notnull,default:0"` // Reputation score of this instance
Version string `bun:",nullzero"` // Version of the software used on 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 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 StatusIDs []string `bun:"statuses,array"` // database IDs of any statuses referenced by this report
Statuses []*Status `bun:"-"` // statuses corresponding to StatusIDs 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 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 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 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 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) { func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSettingsUpdateRequest) (*apimodel.InstanceV1, gtserror.WithCode) {
// fetch the instance entry from the db for processing // fetch the instance entry from the db for processing
host := config.GetHost() 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() reportID := id.NewULID()
report := &gtsmodel.Report{ report := &gtsmodel.Report{
ID: reportID, ID: reportID,
@ -75,6 +82,8 @@ func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form
Comment: form.Comment, Comment: form.Comment,
StatusIDs: form.StatusIDs, StatusIDs: form.StatusIDs,
Statuses: statuses, Statuses: statuses,
RuleIDs: form.RuleIDs,
Rules: rules,
Forwarded: &form.Forward, Forwarded: &form.Forward,
} }

View file

@ -83,6 +83,10 @@ type TypeConverter interface {
InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Instance) (*apimodel.InstanceV1, error) 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 converts a gts instance into its api equivalent for serving at /api/v2/instance
InstanceToAPIV2Instance(ctx context.Context, i *gtsmodel.Instance) (*apimodel.InstanceV2, error) 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 converts a gts relationship into its api equivalent for serving in various places
RelationshipToAPIRelationship(ctx context.Context, r *gtsmodel.Relationship) (*apimodel.Relationship, error) RelationshipToAPIRelationship(ctx context.Context, r *gtsmodel.Relationship) (*apimodel.Relationship, error)
// NotificationToAPINotification converts a gts notification into a api notification // 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 "" 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) { func (c *converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Instance) (*apimodel.InstanceV1, error) {
instance := &apimodel.InstanceV1{ instance := &apimodel.InstanceV1{
URI: i.URI, URI: i.URI,
@ -752,6 +778,7 @@ func (c *converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Ins
ApprovalRequired: config.GetAccountsApprovalRequired(), ApprovalRequired: config.GetAccountsApprovalRequired(),
InvitesEnabled: false, // todo: not supported yet InvitesEnabled: false, // todo: not supported yet
MaxTootChars: uint(config.GetStatusesMaxChars()), MaxTootChars: uint(config.GetStatusesMaxChars()),
Rules: c.InstanceRulesToAPIRules(i.Rules),
} }
if config.GetInstanceInjectMastodonVersion() { if config.GetInstanceInjectMastodonVersion() {
@ -854,7 +881,7 @@ func (c *converter) InstanceToAPIV2Instance(ctx context.Context, i *gtsmodel.Ins
Description: i.Description, Description: i.Description,
Usage: apimodel.InstanceV2Usage{}, // todo: not implemented Usage: apimodel.InstanceV2Usage{}, // todo: not implemented
Languages: []string{}, // todo: not implemented Languages: []string{}, // todo: not implemented
Rules: []interface{}{}, // todo: not implemented Rules: c.InstanceRulesToAPIRules(i.Rules),
} }
if config.GetInstanceInjectMastodonVersion() { if config.GetInstanceInjectMastodonVersion() {
@ -1051,7 +1078,7 @@ func (c *converter) ReportToAPIReport(ctx context.Context, r *gtsmodel.Report) (
Comment: r.Comment, Comment: r.Comment,
Forwarded: *r.Forwarded, Forwarded: *r.Forwarded,
StatusIDs: r.StatusIDs, StatusIDs: r.StatusIDs,
RuleIDs: []int{}, // todo: not supported yet RuleIDs: r.RuleIDs,
} }
if !r.ActionTakenAt.IsZero() { if !r.ActionTakenAt.IsZero() {
@ -1144,6 +1171,20 @@ func (c *converter) ReportToAdminAPIReport(ctx context.Context, r *gtsmodel.Repo
statuses = append(statuses, status) 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 != "" { if ac := r.ActionTaken; ac != "" {
actionTakenComment = &ac actionTakenComment = &ac
} }
@ -1163,7 +1204,7 @@ func (c *converter) ReportToAdminAPIReport(ctx context.Context, r *gtsmodel.Repo
ActionTakenByAccount: actionTakenByAccount, ActionTakenByAccount: actionTakenByAccount,
ActionTakenComment: actionTakenComment, ActionTakenComment: actionTakenComment,
Statuses: statuses, Statuses: statuses,
Rules: []interface{}{}, // not implemented Rules: rules,
}, nil }, nil
} }

View file

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

View file

@ -61,6 +61,7 @@
&gtsmodel.EmojiCategory{}, &gtsmodel.EmojiCategory{},
&gtsmodel.Tombstone{}, &gtsmodel.Tombstone{},
&gtsmodel.Report{}, &gtsmodel.Report{},
&gtsmodel.Rule{},
&gtsmodel.AccountNote{}, &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() { for _, v := range NewTestDomainBlocks() {
if err := db.Put(ctx, v); err != nil { if err := db.Put(ctx, v); err != nil {
log.Panic(nil, err) log.Panic(nil, err)

View file

@ -2021,6 +2021,7 @@ func NewTestReports() map[string]*gtsmodel.Report {
Comment: "dark souls sucks, please yeet this nerd", Comment: "dark souls sucks, please yeet this nerd",
StatusIDs: []string{"01FVW7JHQFSFK166WWKR8CBA6M"}, StatusIDs: []string{"01FVW7JHQFSFK166WWKR8CBA6M"},
Forwarded: util.Ptr(true), Forwarded: util.Ptr(true),
RuleIDs: []string{"01GP3AWY4CRDVRNZKW0TEAMB51", "01GP3DFY9XQ1TJMZT5BGAZPXX3"},
}, },
"remote_account_1_report_local_account_2": { "remote_account_1_report_local_account_2": {
ID: "01GP3DFY9XQ1TJMZT5BGAZPXX7", ID: "01GP3DFY9XQ1TJMZT5BGAZPXX7",
@ -2031,6 +2032,7 @@ func NewTestReports() map[string]*gtsmodel.Report {
TargetAccountID: "01F8MH5NBDF2MV7CTC4Q5128HF", TargetAccountID: "01F8MH5NBDF2MV7CTC4Q5128HF",
Comment: "this is a turtle, not a person, therefore should not be a poster", Comment: "this is a turtle, not a person, therefore should not be a poster",
StatusIDs: []string{}, StatusIDs: []string{},
RuleIDs: []string{},
Forwarded: util.Ptr(true), Forwarded: util.Ptr(true),
ActionTaken: "user was warned not to be a turtle anymore", ActionTaken: "user was warned not to be a turtle anymore",
ActionTakenAt: TimeMustParse("2022-05-15T17:01:56+02:00"), 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. // ActivityWithSignature wraps a pub.Activity along with its signature headers, for testing.
type ActivityWithSignature struct { type ActivityWithSignature struct {
Activity pub.Activity 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) { @media screen and (max-width: 30rem) {
.domain-blocklist .entry { .domain-blocklist .entry {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View file

@ -141,9 +141,11 @@ function DomainBlockForm({ defaultDomain, block = {}, baseUrl }) {
{...disabledForm} {...disabledForm}
/> />
<div className="action-buttons row">
<MutationButton <MutationButton
label="Suspend" label="Suspend"
result={addResult} result={addResult}
showError={false}
{...disabledForm} {...disabledForm}
/> />
@ -155,8 +157,13 @@ function DomainBlockForm({ defaultDomain, block = {}, baseUrl }) {
label="Remove" label="Remove"
result={removeResult} result={removeResult}
className="button danger" className="button danger"
showError={false}
/> />
} }
</div>
{addResult.error && <Error error={addResult.error} />}
{removeResult.error && <Error error={removeResult.error} />}
</form> </form>
); );

View file

@ -21,23 +21,23 @@
const React = require("react"); const React = require("react");
const query = require("../lib/query"); const query = require("../../lib/query");
const { const {
useTextInput, useTextInput,
useFileInput useFileInput
} = require("../lib/form"); } = require("../../lib/form");
const useFormSubmit = require("../lib/form/submit"); const useFormSubmit = require("../../lib/form/submit");
const { const {
TextInput, TextInput,
TextArea, TextArea,
FileInput FileInput
} = require("../components/form/inputs"); } = require("../../components/form/inputs");
const FormWithData = require("../lib/form/form-with-data"); const FormWithData = require("../../lib/form/form-with-data");
const MutationButton = require("../components/form/mutation-button"); const MutationButton = require("../../components/form/mutation-button");
module.exports = function AdminSettings() { module.exports = function AdminSettings() {
return ( 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("Local", { icon: "fa-home", wildcard: true }, require("./admin/emoji/local")),
Item("Remote", { icon: "fa-cloud" }, require("./admin/emoji/remote")) 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 { const {
replaceCacheOnMutation, replaceCacheOnMutation,
removeFromCacheOnMutation, removeFromCacheOnMutation,
domainListToObject domainListToObject,
idListToObject
} = require("../lib"); } = require("../lib");
const base = require("../base"); const base = require("../base");
@ -104,6 +105,51 @@ const endpoints = (build) => ({
return res.accounts ?? []; 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("./import-export")(build),
...require("./custom-emoji")(build), ...require("./custom-emoji")(build),
...require("./reports")(build) ...require("./reports")(build)

View file

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

View file

@ -37,6 +37,13 @@ module.exports = {
(_) => Object.fromEntries(_) (_) => 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) => { replaceCacheOnMutation: makeCacheMutation((draft, newData) => {
Object.assign(draft, newData); Object.assign(draft, newData);
}), }),

View file

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