[feature/frontend] Reports frontend v2 (#3022)

* use apiutil + paging in admin processor+handlers

* we're making it happen

* fix little whoopsie

* styling for report list

* don't youuuu forget about meee don't don't don't don't

* last bits

* sanitize content before showing in report statuses

* update report docs
This commit is contained in:
tobi 2024-06-18 18:18:00 +02:00 committed by GitHub
parent b08c1bd0cb
commit d2b3d37724
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 1389 additions and 726 deletions

View file

@ -20,12 +20,14 @@ Instance moderation settings.
### Reports ### Reports
![List of reports for testing, one resolved and one open.](../assets/admin-settings-reports.png) ![List of reports for testing, showing one open report.](../assets/admin-settings-reports.png)
The reports section shows a list of reports, originating from your local users, or remote instances (shown anonymously as just the name of the instance, without specific username). The reports section shows a list of reports, originating from your local users, or remote instances (shown anonymously as just the name of the instance, without specific username).
Clicking a report shows if it was resolved (with the reasoning if available), more information, and a list of reported toots if selected by the reporting user. You can also use this view to mark a report as resolved, and fill in a comment. Whatever comment you enter here will be visible to the user that created the report, if that user is from your instance. Clicking a report shows if it was resolved (with the reasoning if available), more information, and a list of reported toots if selected by the reporting user. You can also use this view to mark a report as resolved, and fill in a comment. Whatever comment you enter here will be visible to the user that created the report, if that user is from your instance.
![The detailed view of an open report, showing the reported status and the reason for the report.](../assets/admin-settings-report-detail.png)
Clicking on the username of the reported account opens that account in the 'Accounts' view, allowing you to perform moderation actions on it. Clicking on the username of the reported account opens that account in the 'Accounts' view, allowing you to perform moderation actions on it.
### Accounts ### Accounts

View file

@ -4525,6 +4525,8 @@ paths:
- default: 50 - default: 50
description: Number of emojis to return. Less than 1, or not set, means unlimited (all emojis). description: Number of emojis to return. Less than 1, or not set, means unlimited (all emojis).
in: query in: query
maximum: 200
minimum: 0
name: limit name: limit
type: integer type: integer
- description: |- - description: |-
@ -5739,21 +5741,23 @@ paths:
in: query in: query
name: target_account_id name: target_account_id
type: string type: string
- description: Return only reports *OLDER* than the given max ID. The report with the specified ID will not be included in the response. - description: Return only reports *OLDER* than the given max ID (for paging downwards). The report with the specified ID will not be included in the response.
in: query in: query
name: max_id name: max_id
type: string type: string
- description: Return only reports *NEWER* than the given since ID. The report with the specified ID will not be included in the response. This parameter is functionally equivalent to min_id. - description: Return only reports *NEWER* than the given since ID. The report with the specified ID will not be included in the response.
in: query in: query
name: since_id name: since_id
type: string type: string
- description: Return only reports *NEWER* than the given min ID. The report with the specified ID will not be included in the response. This parameter is functionally equivalent to since_id. - description: Return only reports immediately *NEWER* than the given min ID (for paging upwards). The report with the specified ID will not be included in the response.
in: query in: query
name: min_id name: min_id
type: string type: string
- default: 20 - default: 20
description: Number of reports to return. If more than 100 or less than 1, will be clamped to 100. description: Number of reports to return.
in: query in: query
maximum: 100
minimum: 1
name: limit name: limit
type: integer type: integer
produces: produces:
@ -7707,21 +7711,23 @@ paths:
in: query in: query
name: target_account_id name: target_account_id
type: string type: string
- description: Return only reports *OLDER* than the given max ID. The report with the specified ID will not be included in the response. - description: Return only reports *OLDER* than the given max ID (for paging downwards). The report with the specified ID will not be included in the response.
in: query in: query
name: max_id name: max_id
type: string type: string
- description: Return only reports *NEWER* than the given since ID. The report with the specified ID will not be included in the response. This parameter is functionally equivalent to min_id. - description: Return only reports *NEWER* than the given since ID. The report with the specified ID will not be included in the response.
in: query in: query
name: since_id name: since_id
type: string type: string
- description: Return only reports *NEWER* than the given min ID. The report with the specified ID will not be included in the response. This parameter is functionally equivalent to since_id. - description: Return only reports immediately *NEWER* than the given min ID (for paging upwards). The report with the specified ID will not be included in the response.
in: query in: query
name: min_id name: min_id
type: string type: string
- default: 20 - default: 20
description: Number of reports to return. If less than 1, will be clamped to 1. If more than 100, will be clamped to 100. description: Number of reports to return.
in: query in: query
maximum: 100
minimum: 1
name: limit name: limit
type: integer type: integer
produces: produces:

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 109 KiB

View file

@ -116,10 +116,9 @@ func (m *Module) AccountActionPOSTHandler(c *gin.Context) {
return return
} }
targetAcctID := c.Param(IDKey) targetAcctID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if targetAcctID == "" { if errWithCode != nil {
err := errors.New("no account id specified") apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return return
} }
form.TargetID = targetAcctID form.TargetID = targetAcctID

View file

@ -22,6 +22,7 @@ import (
"codeberg.org/gruf/go-debug" "codeberg.org/gruf/go-debug"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/processing" "github.com/superseriousbusiness/gotosocial/internal/processing"
"github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/state"
) )
@ -29,48 +30,40 @@ import (
const ( const (
BasePath = "/v1/admin" BasePath = "/v1/admin"
EmojiPath = BasePath + "/custom_emojis" EmojiPath = BasePath + "/custom_emojis"
EmojiPathWithID = EmojiPath + "/:" + IDKey EmojiPathWithID = EmojiPath + "/:" + apiutil.IDKey
EmojiCategoriesPath = EmojiPath + "/categories" EmojiCategoriesPath = EmojiPath + "/categories"
DomainBlocksPath = BasePath + "/domain_blocks" DomainBlocksPath = BasePath + "/domain_blocks"
DomainBlocksPathWithID = DomainBlocksPath + "/:" + IDKey DomainBlocksPathWithID = DomainBlocksPath + "/:" + apiutil.IDKey
DomainAllowsPath = BasePath + "/domain_allows" DomainAllowsPath = BasePath + "/domain_allows"
DomainAllowsPathWithID = DomainAllowsPath + "/:" + IDKey DomainAllowsPathWithID = DomainAllowsPath + "/:" + apiutil.IDKey
DomainKeysExpirePath = BasePath + "/domain_keys_expire" DomainKeysExpirePath = BasePath + "/domain_keys_expire"
HeaderAllowsPath = BasePath + "/header_allows" HeaderAllowsPath = BasePath + "/header_allows"
HeaderAllowsPathWithID = HeaderAllowsPath + "/:" + IDKey HeaderAllowsPathWithID = HeaderAllowsPath + "/:" + apiutil.IDKey
HeaderBlocksPath = BasePath + "/header_blocks" HeaderBlocksPath = BasePath + "/header_blocks"
HeaderBlocksPathWithID = HeaderBlocksPath + "/:" + IDKey HeaderBlocksPathWithID = HeaderBlocksPath + "/:" + apiutil.IDKey
AccountsV1Path = BasePath + "/accounts" AccountsV1Path = BasePath + "/accounts"
AccountsV2Path = "/v2/admin/accounts" AccountsV2Path = "/v2/admin/accounts"
AccountsPathWithID = AccountsV1Path + "/:" + IDKey AccountsPathWithID = AccountsV1Path + "/:" + apiutil.IDKey
AccountsActionPath = AccountsPathWithID + "/action" AccountsActionPath = AccountsPathWithID + "/action"
AccountsApprovePath = AccountsPathWithID + "/approve" AccountsApprovePath = AccountsPathWithID + "/approve"
AccountsRejectPath = AccountsPathWithID + "/reject" AccountsRejectPath = AccountsPathWithID + "/reject"
MediaCleanupPath = BasePath + "/media_cleanup" MediaCleanupPath = BasePath + "/media_cleanup"
MediaRefetchPath = BasePath + "/media_refetch" MediaRefetchPath = BasePath + "/media_refetch"
ReportsPath = BasePath + "/reports" ReportsPath = BasePath + "/reports"
ReportsPathWithID = ReportsPath + "/:" + IDKey ReportsPathWithID = ReportsPath + "/:" + apiutil.IDKey
ReportsResolvePath = ReportsPathWithID + "/resolve" ReportsResolvePath = ReportsPathWithID + "/resolve"
EmailPath = BasePath + "/email" EmailPath = BasePath + "/email"
EmailTestPath = EmailPath + "/test" EmailTestPath = EmailPath + "/test"
InstanceRulesPath = BasePath + "/instance/rules" InstanceRulesPath = BasePath + "/instance/rules"
InstanceRulesPathWithID = InstanceRulesPath + "/:" + IDKey InstanceRulesPathWithID = InstanceRulesPath + "/:" + apiutil.IDKey
DebugPath = BasePath + "/debug" DebugPath = BasePath + "/debug"
DebugAPUrlPath = DebugPath + "/apurl" DebugAPUrlPath = DebugPath + "/apurl"
DebugClearCachesPath = DebugPath + "/caches/clear" DebugClearCachesPath = DebugPath + "/caches/clear"
IDKey = "id"
FilterQueryKey = "filter" FilterQueryKey = "filter"
MaxShortcodeDomainKey = "max_shortcode_domain" MaxShortcodeDomainKey = "max_shortcode_domain"
MinShortcodeDomainKey = "min_shortcode_domain" MinShortcodeDomainKey = "min_shortcode_domain"
LimitKey = "limit"
DomainQueryKey = "domain" DomainQueryKey = "domain"
ResolvedKey = "resolved"
AccountIDKey = "account_id"
TargetAccountIDKey = "target_account_id"
MaxIDKey = "max_id"
SinceIDKey = "since_id"
MinIDKey = "min_id"
) )
type Module struct { type Module struct {

View file

@ -18,7 +18,6 @@
package admin package admin
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
@ -97,10 +96,9 @@ func (m *Module) EmojiDELETEHandler(c *gin.Context) {
return return
} }
emojiID := c.Param(IDKey) emojiID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if emojiID == "" { if errWithCode != nil {
err := errors.New("no emoji id specified") apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return return
} }

View file

@ -28,6 +28,7 @@ import (
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/admin" "github.com/superseriousbusiness/gotosocial/internal/api/client/admin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/db"
) )
@ -41,7 +42,7 @@ func (suite *EmojiDeleteTestSuite) TestEmojiDelete1() {
path := admin.EmojiPathWithID path := admin.EmojiPathWithID
ctx := suite.newContext(recorder, http.MethodDelete, nil, path, "application/json") ctx := suite.newContext(recorder, http.MethodDelete, nil, path, "application/json")
ctx.AddParam(admin.IDKey, testEmoji.ID) ctx.AddParam(apiutil.IDKey, testEmoji.ID)
suite.adminModule.EmojiDELETEHandler(ctx) suite.adminModule.EmojiDELETEHandler(ctx)
suite.Equal(http.StatusOK, recorder.Code) suite.Equal(http.StatusOK, recorder.Code)
@ -78,7 +79,7 @@ func (suite *EmojiDeleteTestSuite) TestEmojiDelete2() {
path := admin.EmojiPathWithID path := admin.EmojiPathWithID
ctx := suite.newContext(recorder, http.MethodDelete, nil, path, "application/json") ctx := suite.newContext(recorder, http.MethodDelete, nil, path, "application/json")
ctx.AddParam(admin.IDKey, testEmoji.ID) ctx.AddParam(apiutil.IDKey, testEmoji.ID)
suite.adminModule.EmojiDELETEHandler(ctx) suite.adminModule.EmojiDELETEHandler(ctx)
suite.Equal(http.StatusBadRequest, recorder.Code) suite.Equal(http.StatusBadRequest, recorder.Code)
@ -100,7 +101,7 @@ func (suite *EmojiDeleteTestSuite) TestEmojiDeleteNotFound() {
path := admin.EmojiPathWithID path := admin.EmojiPathWithID
ctx := suite.newContext(recorder, http.MethodDelete, nil, path, "application/json") ctx := suite.newContext(recorder, http.MethodDelete, nil, path, "application/json")
ctx.AddParam(admin.IDKey, "01GF8VRXX1R00X7XH8973Z29R1") ctx.AddParam(apiutil.IDKey, "01GF8VRXX1R00X7XH8973Z29R1")
suite.adminModule.EmojiDELETEHandler(ctx) suite.adminModule.EmojiDELETEHandler(ctx)
suite.Equal(http.StatusNotFound, recorder.Code) suite.Equal(http.StatusNotFound, recorder.Code)

View file

@ -18,7 +18,6 @@
package admin package admin
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
@ -82,10 +81,9 @@ func (m *Module) EmojiGETHandler(c *gin.Context) {
return return
} }
emojiID := c.Param(IDKey) emojiID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if emojiID == "" { if errWithCode != nil {
err := errors.New("no emoji id specified") apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return return
} }

View file

@ -27,6 +27,7 @@ import (
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/admin" "github.com/superseriousbusiness/gotosocial/internal/api/client/admin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
) )
type EmojiGetTestSuite struct { type EmojiGetTestSuite struct {
@ -39,7 +40,7 @@ func (suite *EmojiGetTestSuite) TestEmojiGet1() {
path := admin.EmojiPathWithID path := admin.EmojiPathWithID
ctx := suite.newContext(recorder, http.MethodGet, nil, path, "application/json") ctx := suite.newContext(recorder, http.MethodGet, nil, path, "application/json")
ctx.AddParam(admin.IDKey, testEmoji.ID) ctx.AddParam(apiutil.IDKey, testEmoji.ID)
suite.adminModule.EmojiGETHandler(ctx) suite.adminModule.EmojiGETHandler(ctx)
suite.Equal(http.StatusOK, recorder.Code) suite.Equal(http.StatusOK, recorder.Code)
@ -71,7 +72,7 @@ func (suite *EmojiGetTestSuite) TestEmojiGet2() {
path := admin.EmojiPathWithID path := admin.EmojiPathWithID
ctx := suite.newContext(recorder, http.MethodGet, nil, path, "application/json") ctx := suite.newContext(recorder, http.MethodGet, nil, path, "application/json")
ctx.AddParam(admin.IDKey, testEmoji.ID) ctx.AddParam(apiutil.IDKey, testEmoji.ID)
suite.adminModule.EmojiGETHandler(ctx) suite.adminModule.EmojiGETHandler(ctx)
suite.Equal(http.StatusOK, recorder.Code) suite.Equal(http.StatusOK, recorder.Code)
@ -102,7 +103,7 @@ func (suite *EmojiGetTestSuite) TestEmojiGetNotFound() {
path := admin.EmojiPathWithID path := admin.EmojiPathWithID
ctx := suite.newContext(recorder, http.MethodGet, nil, path, "application/json") ctx := suite.newContext(recorder, http.MethodGet, nil, path, "application/json")
ctx.AddParam(admin.IDKey, "01GF8VRXX1R00X7XH8973Z29R1") ctx.AddParam(apiutil.IDKey, "01GF8VRXX1R00X7XH8973Z29R1")
suite.adminModule.EmojiGETHandler(ctx) suite.adminModule.EmojiGETHandler(ctx)
suite.Equal(http.StatusNotFound, recorder.Code) suite.Equal(http.StatusNotFound, recorder.Code)

View file

@ -20,7 +20,6 @@ package admin
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -76,6 +75,8 @@ import (
// type: integer // type: integer
// description: Number of emojis to return. Less than 1, or not set, means unlimited (all emojis). // description: Number of emojis to return. Less than 1, or not set, means unlimited (all emojis).
// default: 50 // default: 50
// minimum: 0
// maximum: 200
// in: query // in: query
// - // -
// name: max_shortcode_domain // name: max_shortcode_domain
@ -142,19 +143,10 @@ func (m *Module) EmojisGETHandler(c *gin.Context) {
maxShortcodeDomain := c.Query(MaxShortcodeDomainKey) maxShortcodeDomain := c.Query(MaxShortcodeDomainKey)
minShortcodeDomain := c.Query(MinShortcodeDomainKey) minShortcodeDomain := c.Query(MinShortcodeDomainKey)
limit := 50 limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 50, 200, 0)
limitString := c.Query(LimitKey) if errWithCode != nil {
if limitString != "" { apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
i, err := strconv.ParseInt(limitString, 10, 32) return
if err != nil {
err := fmt.Errorf("error parsing %s: %s", LimitKey, err)
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
limit = int(i)
}
if limit < 0 {
limit = 0
} }
var domain string var domain string

View file

@ -147,10 +147,9 @@ func (m *Module) EmojiPATCHHandler(c *gin.Context) {
return return
} }
emojiID := c.Param(IDKey) emojiID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if emojiID == "" { if errWithCode != nil {
err := errors.New("no emoji id specified") apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return return
} }

View file

@ -28,6 +28,7 @@ import (
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/admin" "github.com/superseriousbusiness/gotosocial/internal/api/client/admin"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/testrig" "github.com/superseriousbusiness/gotosocial/testrig"
) )
@ -53,7 +54,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateNewCategory() {
bodyBytes := requestBody.Bytes() bodyBytes := requestBody.Bytes()
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType()) ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType())
ctx.AddParam(admin.IDKey, testEmoji.ID) ctx.AddParam(apiutil.IDKey, testEmoji.ID)
// call the handler // call the handler
suite.adminModule.EmojiPATCHHandler(ctx) suite.adminModule.EmojiPATCHHandler(ctx)
@ -130,7 +131,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateSwitchCategory() {
bodyBytes := requestBody.Bytes() bodyBytes := requestBody.Bytes()
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType()) ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType())
ctx.AddParam(admin.IDKey, testEmoji.ID) ctx.AddParam(apiutil.IDKey, testEmoji.ID)
// call the handler // call the handler
suite.adminModule.EmojiPATCHHandler(ctx) suite.adminModule.EmojiPATCHHandler(ctx)
@ -208,7 +209,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyRemoteToLocal() {
bodyBytes := requestBody.Bytes() bodyBytes := requestBody.Bytes()
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType()) ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType())
ctx.AddParam(admin.IDKey, testEmoji.ID) ctx.AddParam(apiutil.IDKey, testEmoji.ID)
// call the handler // call the handler
suite.adminModule.EmojiPATCHHandler(ctx) suite.adminModule.EmojiPATCHHandler(ctx)
@ -284,7 +285,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateDisableEmoji() {
bodyBytes := requestBody.Bytes() bodyBytes := requestBody.Bytes()
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType()) ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType())
ctx.AddParam(admin.IDKey, testEmoji.ID) ctx.AddParam(apiutil.IDKey, testEmoji.ID)
// call the handler // call the handler
suite.adminModule.EmojiPATCHHandler(ctx) suite.adminModule.EmojiPATCHHandler(ctx)
@ -325,7 +326,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateDisableLocalEmoji() {
bodyBytes := requestBody.Bytes() bodyBytes := requestBody.Bytes()
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType()) ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType())
ctx.AddParam(admin.IDKey, testEmoji.ID) ctx.AddParam(apiutil.IDKey, testEmoji.ID)
// call the handler // call the handler
suite.adminModule.EmojiPATCHHandler(ctx) suite.adminModule.EmojiPATCHHandler(ctx)
@ -358,7 +359,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateModifyRemoteEmoji() {
bodyBytes := requestBody.Bytes() bodyBytes := requestBody.Bytes()
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType()) ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType())
ctx.AddParam(admin.IDKey, testEmoji.ID) ctx.AddParam(apiutil.IDKey, testEmoji.ID)
// call the handler // call the handler
suite.adminModule.EmojiPATCHHandler(ctx) suite.adminModule.EmojiPATCHHandler(ctx)
@ -391,7 +392,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateModifyNoParams() {
bodyBytes := requestBody.Bytes() bodyBytes := requestBody.Bytes()
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType()) ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType())
ctx.AddParam(admin.IDKey, testEmoji.ID) ctx.AddParam(apiutil.IDKey, testEmoji.ID)
// call the handler // call the handler
suite.adminModule.EmojiPATCHHandler(ctx) suite.adminModule.EmojiPATCHHandler(ctx)
@ -425,7 +426,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyLocalToLocal() {
bodyBytes := requestBody.Bytes() bodyBytes := requestBody.Bytes()
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType()) ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType())
ctx.AddParam(admin.IDKey, testEmoji.ID) ctx.AddParam(apiutil.IDKey, testEmoji.ID)
// call the handler // call the handler
suite.adminModule.EmojiPATCHHandler(ctx) suite.adminModule.EmojiPATCHHandler(ctx)
@ -459,7 +460,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyEmptyShortcode() {
bodyBytes := requestBody.Bytes() bodyBytes := requestBody.Bytes()
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType()) ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType())
ctx.AddParam(admin.IDKey, testEmoji.ID) ctx.AddParam(apiutil.IDKey, testEmoji.ID)
// call the handler // call the handler
suite.adminModule.EmojiPATCHHandler(ctx) suite.adminModule.EmojiPATCHHandler(ctx)
@ -492,7 +493,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyNoShortcode() {
bodyBytes := requestBody.Bytes() bodyBytes := requestBody.Bytes()
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType()) ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType())
ctx.AddParam(admin.IDKey, testEmoji.ID) ctx.AddParam(apiutil.IDKey, testEmoji.ID)
// call the handler // call the handler
suite.adminModule.EmojiPATCHHandler(ctx) suite.adminModule.EmojiPATCHHandler(ctx)
@ -526,7 +527,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyShortcodeAlreadyInUse() {
bodyBytes := requestBody.Bytes() bodyBytes := requestBody.Bytes()
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType()) ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType())
ctx.AddParam(admin.IDKey, testEmoji.ID) ctx.AddParam(apiutil.IDKey, testEmoji.ID)
// call the handler // call the handler
suite.adminModule.EmojiPATCHHandler(ctx) suite.adminModule.EmojiPATCHHandler(ctx)

View file

@ -18,7 +18,6 @@
package admin package admin
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
@ -85,10 +84,9 @@ func (m *Module) ReportGETHandler(c *gin.Context) {
return return
} }
reportID := c.Param(IDKey) reportID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if reportID == "" { if errWithCode != nil {
err := errors.New("no report id specified") apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return return
} }

View file

@ -18,7 +18,6 @@
package admin package admin
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
@ -107,10 +106,9 @@ func (m *Module) ReportResolvePOSTHandler(c *gin.Context) {
return return
} }
reportID := c.Param(IDKey) reportID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if reportID == "" { if errWithCode != nil {
err := errors.New("no report id specified") apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return return
} }

View file

@ -29,6 +29,7 @@ import (
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/admin" "github.com/superseriousbusiness/gotosocial/internal/api/client/admin"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
@ -65,7 +66,7 @@ func (suite *ReportResolveTestSuite) resolveReport(
// create the request // create the request
ctx.Request = httptest.NewRequest(http.MethodPost, requestURI, nil) ctx.Request = httptest.NewRequest(http.MethodPost, requestURI, nil)
ctx.AddParam(admin.IDKey, targetReportID) ctx.AddParam(apiutil.IDKey, targetReportID)
ctx.Request.Header.Set("accept", "application/json") ctx.Request.Header.Set("accept", "application/json")
if actionTakenComment != nil { if actionTakenComment != nil {
ctx.Request.Form = url.Values{"action_taken_comment": {*actionTakenComment}} ctx.Request.Form = url.Values{"action_taken_comment": {*actionTakenComment}}

View file

@ -20,12 +20,12 @@ package admin
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/paging"
) )
// ReportsGETHandler swagger:operation GET /api/v1/admin/reports adminReports // ReportsGETHandler swagger:operation GET /api/v1/admin/reports adminReports
@ -72,7 +72,7 @@ import (
// name: max_id // name: max_id
// type: string // type: string
// description: >- // description: >-
// Return only reports *OLDER* than the given max ID. // Return only reports *OLDER* than the given max ID (for paging downwards).
// The report with the specified ID will not be included in the response. // The report with the specified ID will not be included in the response.
// in: query // in: query
// - // -
@ -81,23 +81,21 @@ import (
// description: >- // description: >-
// Return only reports *NEWER* than the given since ID. // Return only reports *NEWER* than the given since ID.
// The report with the specified ID will not be included in the response. // The report with the specified ID will not be included in the response.
// This parameter is functionally equivalent to min_id.
// in: query // in: query
// - // -
// name: min_id // name: min_id
// type: string // type: string
// description: >- // description: >-
// Return only reports *NEWER* than the given min ID. // Return only reports immediately *NEWER* than the given min ID (for paging upwards).
// The report with the specified ID will not be included in the response. // The report with the specified ID will not be included in the response.
// This parameter is functionally equivalent to since_id.
// in: query // in: query
// - // -
// name: limit // name: limit
// type: integer // type: integer
// description: >- // description: Number of reports to return.
// Number of reports to return.
// If more than 100 or less than 1, will be clamped to 100.
// default: 20 // default: 20
// minimum: 1
// maximum: 100
// in: query // in: query
// //
// security: // security:
@ -144,34 +142,30 @@ func (m *Module) ReportsGETHandler(c *gin.Context) {
return return
} }
var resolved *bool resolved, errWithCode := apiutil.ParseResolved(c.Query(apiutil.ResolvedKey), nil)
if resolvedString := c.Query(ResolvedKey); resolvedString != "" { if errWithCode != nil {
i, err := strconv.ParseBool(resolvedString) apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
if err != nil { return
err := fmt.Errorf("error parsing %s: %s", ResolvedKey, err)
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
resolved = &i
} }
limit := 20 page, errWithCode := paging.ParseIDPage(c,
if limitString := c.Query(LimitKey); limitString != "" { 1, // min limit
i, err := strconv.Atoi(limitString) 100, // max limit
if err != nil { 20, // default limit
err := fmt.Errorf("error parsing %s: %s", LimitKey, err) )
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) if errWithCode != nil {
return apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
} return
// normalize
if i < 1 || i > 100 {
i = 100
}
limit = i
} }
resp, errWithCode := m.processor.Admin().ReportsGet(c.Request.Context(), authed.Account, resolved, c.Query(AccountIDKey), c.Query(TargetAccountIDKey), c.Query(MaxIDKey), c.Query(SinceIDKey), c.Query(MinIDKey), limit) resp, errWithCode := m.processor.Admin().ReportsGet(
c.Request.Context(),
authed.Account,
resolved,
c.Query(apiutil.AccountIDKey),
c.Query(apiutil.TargetAccountIDKey),
page,
)
if errWithCode != nil { if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return return

View file

@ -28,6 +28,7 @@ import (
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/admin" "github.com/superseriousbusiness/gotosocial/internal/api/client/admin"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
@ -63,24 +64,24 @@ func (suite *ReportsGetTestSuite) getReports(
ctx.Set(oauth.SessionAuthorizedUser, user) ctx.Set(oauth.SessionAuthorizedUser, user)
// create the request URI // create the request URI
requestPath := admin.ReportsPath + "?" + admin.LimitKey + "=" + strconv.Itoa(limit) requestPath := admin.ReportsPath + "?" + apiutil.LimitKey + "=" + strconv.Itoa(limit)
if resolved != nil { if resolved != nil {
requestPath = requestPath + "&" + admin.ResolvedKey + "=" + strconv.FormatBool(*resolved) requestPath = requestPath + "&" + apiutil.ResolvedKey + "=" + strconv.FormatBool(*resolved)
} }
if accountID != "" { if accountID != "" {
requestPath = requestPath + "&" + admin.AccountIDKey + "=" + accountID requestPath = requestPath + "&" + apiutil.AccountIDKey + "=" + accountID
} }
if targetAccountID != "" { if targetAccountID != "" {
requestPath = requestPath + "&" + admin.TargetAccountIDKey + "=" + targetAccountID requestPath = requestPath + "&" + apiutil.TargetAccountIDKey + "=" + targetAccountID
} }
if maxID != "" { if maxID != "" {
requestPath = requestPath + "&" + admin.MaxIDKey + "=" + maxID requestPath = requestPath + "&" + apiutil.MaxIDKey + "=" + maxID
} }
if sinceID != "" { if sinceID != "" {
requestPath = requestPath + "&" + admin.SinceIDKey + "=" + sinceID requestPath = requestPath + "&" + apiutil.SinceIDKey + "=" + sinceID
} }
if minID != "" { if minID != "" {
requestPath = requestPath + "&" + admin.MinIDKey + "=" + minID requestPath = requestPath + "&" + apiutil.MinIDKey + "=" + minID
} }
baseURI := config.GetProtocol() + "://" + config.GetHost() baseURI := config.GetProtocol() + "://" + config.GetHost()
requestURI := baseURI + "/api/" + requestPath requestURI := baseURI + "/api/" + requestPath
@ -766,7 +767,7 @@ func (suite *ReportsGetTestSuite) TestReportsGetCreatedByAccount() {
} }
]`, string(b)) ]`, string(b))
suite.Equal(`<http://localhost:8080/api/v1/admin/reports?limit=20&max_id=01GP3AWY4CRDVRNZKW0TEAMB5R&account_id=01F8MH5NBDF2MV7CTC4Q5128HF>; rel="next", <http://localhost:8080/api/v1/admin/reports?limit=20&min_id=01GP3AWY4CRDVRNZKW0TEAMB5R&account_id=01F8MH5NBDF2MV7CTC4Q5128HF>; rel="prev"`, link) suite.Equal(`<http://localhost:8080/api/v1/admin/reports?account_id=01F8MH5NBDF2MV7CTC4Q5128HF&limit=20&max_id=01GP3AWY4CRDVRNZKW0TEAMB5R>; rel="next", <http://localhost:8080/api/v1/admin/reports?account_id=01F8MH5NBDF2MV7CTC4Q5128HF&limit=20&min_id=01GP3AWY4CRDVRNZKW0TEAMB5R>; rel="prev"`, link)
} }
func (suite *ReportsGetTestSuite) TestReportsGetTargetAccount() { func (suite *ReportsGetTestSuite) TestReportsGetTargetAccount() {
@ -1028,8 +1029,8 @@ func (suite *ReportsGetTestSuite) TestReportsGetZeroLimit() {
suite.NoError(err) suite.NoError(err)
suite.Len(reports, 2) suite.Len(reports, 2)
// Limit in Link header should be set to 100 // Limit in Link header should be set to default (20)
suite.Equal(`<http://localhost:8080/api/v1/admin/reports?limit=100&max_id=01GP3AWY4CRDVRNZKW0TEAMB5R>; rel="next", <http://localhost:8080/api/v1/admin/reports?limit=100&min_id=01GP3DFY9XQ1TJMZT5BGAZPXX7>; rel="prev"`, link) suite.Equal(`<http://localhost:8080/api/v1/admin/reports?limit=20&max_id=01GP3AWY4CRDVRNZKW0TEAMB5R>; rel="next", <http://localhost:8080/api/v1/admin/reports?limit=20&min_id=01GP3DFY9XQ1TJMZT5BGAZPXX7>; rel="prev"`, link)
} }
func (suite *ReportsGetTestSuite) TestReportsGetHighLimit() { func (suite *ReportsGetTestSuite) TestReportsGetHighLimit() {

View file

@ -18,7 +18,6 @@
package admin package admin
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
@ -95,10 +94,9 @@ func (m *Module) RuleDELETEHandler(c *gin.Context) {
return return
} }
ruleID := c.Param(IDKey) ruleID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if ruleID == "" { if errWithCode != nil {
err := errors.New("no rule id specified") apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return return
} }

View file

@ -18,7 +18,6 @@
package admin package admin
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
@ -85,10 +84,9 @@ func (m *Module) RuleGETHandler(c *gin.Context) {
return return
} }
ruleID := c.Param(IDKey) ruleID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if ruleID == "" { if errWithCode != nil {
err := errors.New("no rule id specified") apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return return
} }

View file

@ -18,7 +18,6 @@
package admin package admin
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
@ -87,10 +86,9 @@ func (m *Module) RulePATCHHandler(c *gin.Context) {
return return
} }
ruleID := c.Param(IDKey) ruleID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if ruleID == "" { if errWithCode != nil {
err := errors.New("no rule id specified") apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return return
} }

View file

@ -18,7 +18,6 @@
package reports package reports
import ( import (
"errors"
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -77,10 +76,9 @@ func (m *Module) ReportGETHandler(c *gin.Context) {
return return
} }
targetReportID := c.Param(IDKey) targetReportID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if targetReportID == "" { if errWithCode != nil {
err := errors.New("no report id specified") apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return return
} }

View file

@ -145,7 +145,7 @@ func (suite *ReportGetTestSuite) TestGetReport2() {
} }
func (suite *ReportGetTestSuite) TestGetReport3() { func (suite *ReportGetTestSuite) TestGetReport3() {
report, err := suite.getReport(http.StatusBadRequest, `{"error":"Bad Request: no report id specified"}`, "") report, err := suite.getReport(http.StatusBadRequest, `{"error":"Bad Request: required key id was not set or had empty value"}`, "")
suite.NoError(err) suite.NoError(err)
suite.Nil(report) suite.Nil(report)
} }

View file

@ -21,19 +21,13 @@ import (
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/processing" "github.com/superseriousbusiness/gotosocial/internal/processing"
) )
const ( const (
BasePath = "/v1/reports" BasePath = "/v1/reports"
IDKey = "id" BasePathWithID = BasePath + "/:" + apiutil.IDKey
ResolvedKey = "resolved"
TargetAccountIDKey = "target_account_id"
MaxIDKey = "max_id"
SinceIDKey = "since_id"
MinIDKey = "min_id"
LimitKey = "limit"
BasePathWithID = BasePath + "/:" + IDKey
) )
type Module struct { type Module struct {

View file

@ -18,14 +18,13 @@
package reports package reports
import ( import (
"fmt"
"net/http" "net/http"
"strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/paging"
) )
// ReportsGETHandler swagger:operation GET /api/v1/reports reports // ReportsGETHandler swagger:operation GET /api/v1/reports reports
@ -67,7 +66,7 @@ import (
// name: max_id // name: max_id
// type: string // type: string
// description: >- // description: >-
// Return only reports *OLDER* than the given max ID. // Return only reports *OLDER* than the given max ID (for paging downwards).
// The report with the specified ID will not be included in the response. // The report with the specified ID will not be included in the response.
// in: query // in: query
// - // -
@ -76,24 +75,21 @@ import (
// description: >- // description: >-
// Return only reports *NEWER* than the given since ID. // Return only reports *NEWER* than the given since ID.
// The report with the specified ID will not be included in the response. // The report with the specified ID will not be included in the response.
// This parameter is functionally equivalent to min_id.
// in: query // in: query
// - // -
// name: min_id // name: min_id
// type: string // type: string
// description: >- // description: >-
// Return only reports *NEWER* than the given min ID. // Return only reports immediately *NEWER* than the given min ID (for paging upwards).
// The report with the specified ID will not be included in the response. // The report with the specified ID will not be included in the response.
// This parameter is functionally equivalent to since_id.
// in: query // in: query
// - // -
// name: limit // name: limit
// type: integer // type: integer
// description: >- // description: Number of reports to return.
// Number of reports to return.
// If less than 1, will be clamped to 1.
// If more than 100, will be clamped to 100.
// default: 20 // default: 20
// minimum: 1
// maximum: 100
// in: query // in: query
// //
// security: // security:
@ -134,36 +130,29 @@ func (m *Module) ReportsGETHandler(c *gin.Context) {
return return
} }
var resolved *bool resolved, errWithCode := apiutil.ParseResolved(c.Query(apiutil.ResolvedKey), nil)
if resolvedString := c.Query(ResolvedKey); resolvedString != "" { if errWithCode != nil {
i, err := strconv.ParseBool(resolvedString) apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
if err != nil { return
err := fmt.Errorf("error parsing %s: %s", ResolvedKey, err)
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
resolved = &i
} }
limit := 20 page, errWithCode := paging.ParseIDPage(c,
if limitString := c.Query(LimitKey); limitString != "" { 1, // min limit
i, err := strconv.Atoi(limitString) 100, // max limit
if err != nil { 20, // default limit
err := fmt.Errorf("error parsing %s: %s", LimitKey, err) )
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) if errWithCode != nil {
return apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
} return
// normalize
if i <= 0 {
i = 1
} else if i >= 100 {
i = 100
}
limit = i
} }
resp, errWithCode := m.processor.Report().GetMultiple(c.Request.Context(), authed.Account, resolved, c.Query(TargetAccountIDKey), c.Query(MaxIDKey), c.Query(SinceIDKey), c.Query(MinIDKey), limit) resp, errWithCode := m.processor.Report().GetMultiple(
c.Request.Context(),
authed.Account,
resolved,
c.Query(apiutil.TargetAccountIDKey),
page,
)
if errWithCode != nil { if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return return

View file

@ -29,6 +29,7 @@ import (
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/reports" "github.com/superseriousbusiness/gotosocial/internal/api/client/reports"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/oauth"
@ -61,21 +62,21 @@ func (suite *ReportsGetTestSuite) getReports(
ctx.Set(oauth.SessionAuthorizedUser, user) ctx.Set(oauth.SessionAuthorizedUser, user)
// create the request URI // create the request URI
requestPath := reports.BasePath + "?" + reports.LimitKey + "=" + strconv.Itoa(limit) requestPath := reports.BasePath + "?" + apiutil.LimitKey + "=" + strconv.Itoa(limit)
if resolved != nil { if resolved != nil {
requestPath = requestPath + "&" + reports.ResolvedKey + "=" + strconv.FormatBool(*resolved) requestPath = requestPath + "&" + apiutil.ResolvedKey + "=" + strconv.FormatBool(*resolved)
} }
if targetAccountID != "" { if targetAccountID != "" {
requestPath = requestPath + "&" + reports.TargetAccountIDKey + "=" + targetAccountID requestPath = requestPath + "&" + apiutil.TargetAccountIDKey + "=" + targetAccountID
} }
if maxID != "" { if maxID != "" {
requestPath = requestPath + "&" + reports.MaxIDKey + "=" + maxID requestPath = requestPath + "&" + apiutil.MaxIDKey + "=" + maxID
} }
if sinceID != "" { if sinceID != "" {
requestPath = requestPath + "&" + reports.SinceIDKey + "=" + sinceID requestPath = requestPath + "&" + apiutil.SinceIDKey + "=" + sinceID
} }
if minID != "" { if minID != "" {
requestPath = requestPath + "&" + reports.MinIDKey + "=" + minID requestPath = requestPath + "&" + apiutil.MinIDKey + "=" + minID
} }
baseURI := config.GetProtocol() + "://" + config.GetHost() baseURI := config.GetProtocol() + "://" + config.GetHost()
requestURI := baseURI + "/api/" + requestPath requestURI := baseURI + "/api/" + requestPath

View file

@ -247,7 +247,7 @@ func (m *Module) SearchGETHandler(c *gin.Context) {
Resolve: resolve, Resolve: resolve,
Following: following, Following: following,
ExcludeUnreviewed: excludeUnreviewed, ExcludeUnreviewed: excludeUnreviewed,
AccountID: c.Query(apiutil.SearchAccountIDKey), AccountID: c.Query(apiutil.AccountIDKey),
APIv1: apiVersion == apiutil.APIv1, APIv1: apiVersion == apiutil.APIv1,
} }

View file

@ -105,7 +105,7 @@ func (suite *SearchGetTestSuite) getSearch(
} }
if fromAccountID != nil { if fromAccountID != nil {
queryParts = append(queryParts, apiutil.SearchAccountIDKey+"="+url.QueryEscape(*fromAccountID)) queryParts = append(queryParts, apiutil.AccountIDKey+"="+url.QueryEscape(*fromAccountID))
} }
requestURL.RawQuery = strings.Join(queryParts, "&") requestURL.RawQuery = strings.Join(queryParts, "&")

View file

@ -34,13 +34,16 @@ const (
/* Common keys */ /* Common keys */
IDKey = "id" IDKey = "id"
LimitKey = "limit" LimitKey = "limit"
LocalKey = "local" LocalKey = "local"
MaxIDKey = "max_id" MaxIDKey = "max_id"
SinceIDKey = "since_id" SinceIDKey = "since_id"
MinIDKey = "min_id" MinIDKey = "min_id"
UsernameKey = "username" UsernameKey = "username"
AccountIDKey = "account_id"
TargetAccountIDKey = "target_account_id"
ResolvedKey = "resolved"
/* AP endpoint keys */ /* AP endpoint keys */
@ -55,7 +58,6 @@ const (
SearchQueryKey = "q" SearchQueryKey = "q"
SearchResolveKey = "resolve" SearchResolveKey = "resolve"
SearchTypeKey = "type" SearchTypeKey = "type"
SearchAccountIDKey = "account_id"
/* Tag keys */ /* Tag keys */
@ -132,6 +134,10 @@ func ParseLocal(value string, defaultValue bool) (bool, gtserror.WithCode) {
return parseBool(value, defaultValue, LocalKey) return parseBool(value, defaultValue, LocalKey)
} }
func ParseResolved(value string, defaultValue *bool) (*bool, gtserror.WithCode) {
return parseBoolPtr(value, defaultValue, ResolvedKey)
}
func ParseSearchExcludeUnreviewed(value string, defaultValue bool) (bool, gtserror.WithCode) { func ParseSearchExcludeUnreviewed(value string, defaultValue bool) (bool, gtserror.WithCode) {
return parseBool(value, defaultValue, SearchExcludeUnreviewedKey) return parseBool(value, defaultValue, SearchExcludeUnreviewedKey)
} }
@ -289,6 +295,19 @@ func parseBool(value string, defaultValue bool, key string) (bool, gtserror.With
return i, nil return i, nil
} }
func parseBoolPtr(value string, defaultValue *bool, key string) (*bool, gtserror.WithCode) {
if value == "" {
return defaultValue, nil
}
i, err := strconv.ParseBool(value)
if err != nil {
return defaultValue, parseError(key, value, defaultValue, err)
}
return &i, nil
}
func parseInt(value string, defaultValue int, max int, min int, key string) (int, gtserror.WithCode) { func parseInt(value string, defaultValue int, max int, min int, key string) (int, gtserror.WithCode) {
if value == "" { if value == "" {
return defaultValue, nil return defaultValue, nil

View file

@ -20,6 +20,7 @@ package bundb
import ( import (
"context" "context"
"errors" "errors"
"slices"
"time" "time"
"github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/db"
@ -27,6 +28,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
@ -51,14 +53,23 @@ func (r *reportDB) GetReportByID(ctx context.Context, id string) (*gtsmodel.Repo
) )
} }
func (r *reportDB) GetReports(ctx context.Context, resolved *bool, accountID string, targetAccountID string, maxID string, sinceID string, minID string, limit int) ([]*gtsmodel.Report, error) { func (r *reportDB) GetReports(ctx context.Context, resolved *bool, accountID string, targetAccountID string, page *paging.Page) ([]*gtsmodel.Report, error) {
reportIDs := []string{} var (
// Get paging params.
minID = page.GetMin()
maxID = page.GetMax()
limit = page.GetLimit()
order = page.GetOrder()
// Make educated guess for slice size
reportIDs = make([]string, 0, limit)
)
q := r.db. q := r.db.
NewSelect(). NewSelect().
TableExpr("? AS ?", bun.Ident("reports"), bun.Ident("report")). TableExpr("? AS ?", bun.Ident("reports"), bun.Ident("report")).
Column("report.id"). // Select only IDs from table.
Order("report.id DESC") Column("report.id")
if resolved != nil { if resolved != nil {
i := bun.Ident("report.action_taken_by_account_id") i := bun.Ident("report.action_taken_by_account_id")
@ -77,22 +88,32 @@ func (r *reportDB) GetReports(ctx context.Context, resolved *bool, accountID str
q = q.Where("? = ?", bun.Ident("report.target_account_id"), targetAccountID) q = q.Where("? = ?", bun.Ident("report.target_account_id"), targetAccountID)
} }
// Return only reports with id
// lower than provided maxID.
if maxID != "" { if maxID != "" {
q = q.Where("? < ?", bun.Ident("report.id"), maxID) q = q.Where("? < ?", bun.Ident("report.id"), maxID)
} }
if sinceID != "" { // Return only reports with id
q = q.Where("? > ?", bun.Ident("report.id"), minID) // greater than provided minID.
}
if minID != "" { if minID != "" {
q = q.Where("? > ?", bun.Ident("report.id"), minID) q = q.Where("? > ?", bun.Ident("report.id"), minID)
} }
if limit != 0 { if limit > 0 {
// Limit amount of
// reports returned.
q = q.Limit(limit) q = q.Limit(limit)
} }
if order == paging.OrderAscending {
// Page up.
q = q.OrderExpr("? ASC", bun.Ident("report.id"))
} else {
// Page down.
q = q.OrderExpr("? DESC", bun.Ident("report.id"))
}
if err := q.Scan(ctx, &reportIDs); err != nil { if err := q.Scan(ctx, &reportIDs); err != nil {
return nil, err return nil, err
} }
@ -102,6 +123,12 @@ func (r *reportDB) GetReports(ctx context.Context, resolved *bool, accountID str
return nil, db.ErrNoEntries return nil, db.ErrNoEntries
} }
// If we're paging up, we still want reports
// to be sorted by ID desc, so reverse ids slice.
if order == paging.OrderAscending {
slices.Reverse(reportIDs)
}
// Allocate return slice (will be at most len reportIDs) // Allocate return slice (will be at most len reportIDs)
reports := make([]*gtsmodel.Report, 0, len(reportIDs)) reports := make([]*gtsmodel.Report, 0, len(reportIDs))
for _, id := range reportIDs { for _, id := range reportIDs {

View file

@ -24,6 +24,8 @@ import (
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/util" "github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/testrig" "github.com/superseriousbusiness/gotosocial/testrig"
) )
@ -61,14 +63,109 @@ func (suite *ReportTestSuite) TestGetReportByURI() {
} }
func (suite *ReportTestSuite) TestGetAllReports() { func (suite *ReportTestSuite) TestGetAllReports() {
reports, err := suite.db.GetReports(context.Background(), nil, "", "", "", "", "", 0) reports, err := suite.db.GetReports(
context.Background(),
nil,
"",
"",
&paging.Page{},
)
suite.NoError(err) suite.NoError(err)
suite.NotEmpty(reports) suite.NotEmpty(reports)
} }
func (suite *ReportTestSuite) TestReportPagingDown() {
// Get one from the top.
reports1, err := suite.db.GetReports(
context.Background(),
nil,
"",
"",
&paging.Page{
Limit: 1,
},
)
if err != nil {
suite.FailNow(err.Error())
}
if l := len(reports1); l != 1 {
suite.FailNowf("", "expected reports len 1, got %d", l)
}
id1 := reports1[0].ID
// Use this one to page down.
reports2, err := suite.db.GetReports(
context.Background(),
nil,
"",
"",
&paging.Page{
Limit: 1,
Max: paging.MaxID(id1),
},
)
if err != nil {
suite.FailNow(err.Error())
}
if l := len(reports2); l != 1 {
suite.FailNowf("", "expected reports len 1, got %d", l)
}
id2 := reports2[0].ID
suite.Greater(id1, id2)
}
func (suite *ReportTestSuite) TestReportPagingUp() {
// Get one from the bottom.
reports1, err := suite.db.GetReports(
context.Background(),
nil,
"",
"",
&paging.Page{
Limit: 1,
Min: paging.MinID(id.Lowest),
},
)
if err != nil {
suite.FailNow(err.Error())
}
if l := len(reports1); l != 1 {
suite.FailNowf("", "expected reports len 1, got %d", l)
}
id1 := reports1[0].ID
// Use this one to page up.
reports2, err := suite.db.GetReports(
context.Background(),
nil,
"",
"",
&paging.Page{
Limit: 1,
Min: paging.MinID(id1),
},
)
if err != nil {
suite.FailNow(err.Error())
}
if l := len(reports2); l != 1 {
suite.FailNowf("", "expected reports len 1, got %d", l)
}
id2 := reports2[0].ID
suite.Less(id1, id2)
}
func (suite *ReportTestSuite) TestGetAllReportsByAccountID() { func (suite *ReportTestSuite) TestGetAllReportsByAccountID() {
accountID := suite.testAccounts["local_account_2"].ID accountID := suite.testAccounts["local_account_2"].ID
reports, err := suite.db.GetReports(context.Background(), nil, accountID, "", "", "", "", 0) reports, err := suite.db.GetReports(
context.Background(),
nil,
accountID,
"",
&paging.Page{},
)
suite.NoError(err) suite.NoError(err)
suite.NotEmpty(reports) suite.NotEmpty(reports)
for _, r := range reports { for _, r := range reports {

View file

@ -21,6 +21,7 @@ import (
"context" "context"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/paging"
) )
// Report handles getting/creation/deletion/updating of user reports/flags. // Report handles getting/creation/deletion/updating of user reports/flags.
@ -30,7 +31,7 @@ type Report interface {
// GetReports gets limit n reports using the given parameters. // GetReports gets limit n reports using the given parameters.
// Parameters that are empty / zero are ignored. // Parameters that are empty / zero are ignored.
GetReports(ctx context.Context, resolved *bool, accountID string, targetAccountID string, maxID string, sinceID string, minID string, limit int) ([]*gtsmodel.Report, error) GetReports(ctx context.Context, resolved *bool, accountID string, targetAccountID string, page *paging.Page) ([]*gtsmodel.Report, error)
// PopulateReport populates the struct pointers on the given report. // PopulateReport populates the struct pointers on the given report.
PopulateReport(ctx context.Context, report *gtsmodel.Report) error PopulateReport(ctx context.Context, report *gtsmodel.Report) error

View file

@ -21,73 +21,81 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net/url"
"strconv" "strconv"
"time" "time"
"github.com/superseriousbusiness/gotosocial/internal/ap" "github.com/superseriousbusiness/gotosocial/internal/ap"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/messages" "github.com/superseriousbusiness/gotosocial/internal/messages"
"github.com/superseriousbusiness/gotosocial/internal/util" "github.com/superseriousbusiness/gotosocial/internal/paging"
) )
// ReportsGet returns all reports stored on this instance, with the given parameters. // ReportsGet returns reports stored on this
// instance, with the given parameters.
func (p *Processor) ReportsGet( func (p *Processor) ReportsGet(
ctx context.Context, ctx context.Context,
account *gtsmodel.Account, account *gtsmodel.Account,
resolved *bool, resolved *bool,
accountID string, accountID string,
targetAccountID string, targetAccountID string,
maxID string, page *paging.Page,
sinceID string,
minID string,
limit int,
) (*apimodel.PageableResponse, gtserror.WithCode) { ) (*apimodel.PageableResponse, gtserror.WithCode) {
reports, err := p.state.DB.GetReports(ctx, resolved, accountID, targetAccountID, maxID, sinceID, minID, limit) reports, err := p.state.DB.GetReports(
ctx,
resolved,
accountID,
targetAccountID,
page,
)
if err != nil && !errors.Is(err, db.ErrNoEntries) { if err != nil && !errors.Is(err, db.ErrNoEntries) {
return nil, gtserror.NewErrorInternalError(err) return nil, gtserror.NewErrorInternalError(err)
} }
count := len(reports) count := len(reports)
if count == 0 { if count == 0 {
return util.EmptyPageableResponse(), nil return paging.EmptyResponse(), nil
} }
var ( // Get the lowest and highest
items = make([]interface{}, 0, count) // ID values, used for paging.
nextMaxIDValue = reports[count-1].ID lo := reports[count-1].ID
prevMinIDValue = reports[0].ID hi := reports[0].ID
)
// Convert each report to API model.
items := make([]interface{}, 0, count)
for _, r := range reports { for _, r := range reports {
item, err := p.converter.ReportToAdminAPIReport(ctx, r, account) item, err := p.converter.ReportToAdminAPIReport(ctx, r, account)
if err != nil { if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting report to api: %s", err)) err := fmt.Errorf("error converting report to api: %s", err)
return nil, gtserror.NewErrorInternalError(err)
} }
items = append(items, item) items = append(items, item)
} }
extraQueryParams := make([]string, 0, 3) // Assemble next/prev page queries.
query := make(url.Values, 3)
if resolved != nil { if resolved != nil {
extraQueryParams = append(extraQueryParams, "resolved="+strconv.FormatBool(*resolved)) query.Set(apiutil.ResolvedKey, strconv.FormatBool(*resolved))
} }
if accountID != "" { if accountID != "" {
extraQueryParams = append(extraQueryParams, "account_id="+accountID) query.Set(apiutil.AccountIDKey, accountID)
} }
if targetAccountID != "" { if targetAccountID != "" {
extraQueryParams = append(extraQueryParams, "target_account_id="+targetAccountID) query.Set(apiutil.TargetAccountIDKey, targetAccountID)
} }
return util.PackagePageableResponse(util.PageableResponseParams{ return paging.PackageResponse(paging.ResponseParams{
Items: items, Items: items,
Path: "/api/v1/admin/reports", Path: "/api/v1/admin/reports",
NextMaxIDValue: nextMaxIDValue, Next: page.Next(lo, hi),
PrevMinIDValue: prevMinIDValue, Prev: page.Prev(lo, hi),
Limit: limit, Query: query,
ExtraQueryParams: extraQueryParams, }), nil
})
} }
// ReportGet returns one report, with the given ID. // ReportGet returns one report, with the given ID.

View file

@ -21,13 +21,15 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net/url"
"strconv" "strconv"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/util" "github.com/superseriousbusiness/gotosocial/internal/paging"
) )
// Get returns the user view of a moderation report, with the given id. // Get returns the user view of a moderation report, with the given id.
@ -53,53 +55,61 @@ func (p *Processor) Get(ctx context.Context, account *gtsmodel.Account, id strin
return apiReport, nil return apiReport, nil
} }
// GetMultiple returns multiple reports created by the given account, filtered according to the provided parameters. // GetMultiple returns reports created by the given account,
// filtered according to the provided parameters.
func (p *Processor) GetMultiple( func (p *Processor) GetMultiple(
ctx context.Context, ctx context.Context,
account *gtsmodel.Account, account *gtsmodel.Account,
resolved *bool, resolved *bool,
targetAccountID string, targetAccountID string,
maxID string, page *paging.Page,
sinceID string,
minID string,
limit int,
) (*apimodel.PageableResponse, gtserror.WithCode) { ) (*apimodel.PageableResponse, gtserror.WithCode) {
reports, err := p.state.DB.GetReports(ctx, resolved, account.ID, targetAccountID, maxID, sinceID, minID, limit) reports, err := p.state.DB.GetReports(
ctx,
resolved,
account.ID,
targetAccountID,
page,
)
if err != nil && !errors.Is(err, db.ErrNoEntries) { if err != nil && !errors.Is(err, db.ErrNoEntries) {
return nil, gtserror.NewErrorInternalError(err) return nil, gtserror.NewErrorInternalError(err)
} }
count := len(reports) count := len(reports)
if count == 0 { if count == 0 {
return util.EmptyPageableResponse(), nil return paging.EmptyResponse(), nil
} }
items := make([]interface{}, 0, count) // Get the lowest and highest
nextMaxIDValue := reports[count-1].ID // ID values, used for paging.
prevMinIDValue := reports[0].ID lo := reports[count-1].ID
hi := reports[0].ID
// Convert each report to API model.
items := make([]interface{}, 0, count)
for _, r := range reports { for _, r := range reports {
item, err := p.converter.ReportToAPIReport(ctx, r) item, err := p.converter.ReportToAPIReport(ctx, r)
if err != nil { if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting report to api: %s", err)) err := fmt.Errorf("error converting report to api: %s", err)
return nil, gtserror.NewErrorInternalError(err)
} }
items = append(items, item) items = append(items, item)
} }
extraQueryParams := []string{} // Assemble next/prev page queries.
query := make(url.Values, 3)
if resolved != nil { if resolved != nil {
extraQueryParams = append(extraQueryParams, "resolved="+strconv.FormatBool(*resolved)) query.Set(apiutil.ResolvedKey, strconv.FormatBool(*resolved))
} }
if targetAccountID != "" { if targetAccountID != "" {
extraQueryParams = append(extraQueryParams, "target_account_id="+targetAccountID) query.Set(apiutil.TargetAccountIDKey, targetAccountID)
} }
return util.PackagePageableResponse(util.PageableResponseParams{ return paging.PackageResponse(paging.ResponseParams{
Items: items, Items: items,
Path: "/api/v1/reports", Path: "/api/v1/reports",
NextMaxIDValue: nextMaxIDValue, Next: page.Next(lo, hi),
PrevMinIDValue: prevMinIDValue, Prev: page.Prev(lo, hi),
Limit: limit, Query: query,
ExtraQueryParams: extraQueryParams, }), nil
})
} }

View file

@ -33,6 +33,7 @@
"react-redux": "^8.1.3", "react-redux": "^8.1.3",
"redux": "^4.2.0", "redux": "^4.2.0",
"redux-persist": "^6.0.0", "redux-persist": "^6.0.0",
"sanitize-html": "^2.13.0",
"skulk": "^0.0.8-fix", "skulk": "^0.0.8-fix",
"wouter": "^3.1.0" "wouter": "^3.1.0"
}, },
@ -49,6 +50,7 @@
"@types/parse-link-header": "^2.0.3", "@types/parse-link-header": "^2.0.3",
"@types/psl": "^1.1.1", "@types/psl": "^1.1.1",
"@types/react-dom": "^18.2.8", "@types/react-dom": "^18.2.8",
"@types/sanitize-html": "^2.11.0",
"@typescript-eslint/eslint-plugin": "^6.7.4", "@typescript-eslint/eslint-plugin": "^6.7.4",
"@typescript-eslint/parser": "^6.7.4", "@typescript-eslint/parser": "^6.7.4",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",

View file

@ -1,56 +0,0 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from "react";
import { useVerifyCredentialsQuery } from "../lib/query/oauth";
export default function FakeToot({ children }) {
const { data: account = {
avatar: "/assets/default_avatars/GoToSocial_icon1.png",
display_name: "",
username: ""
} } = useVerifyCredentialsQuery();
return (
<article className="status expanded">
<header className="status-header">
<address>
<a style={{margin: 0}}>
<img className="avatar" src={account.avatar} alt="" />
<dl className="author-strap">
<dt className="sr-only">Display name</dt>
<dd className="displayname text-cutoff">
{account.display_name.trim().length > 0 ? account.display_name : account.username}
</dd>
<dt className="sr-only">Username</dt>
<dd className="username text-cutoff">@{account.username}</dd>
</dl>
</a>
</address>
</header>
<section className="status-body">
<div className="text">
<div className="content">
{children}
</div>
</div>
</section>
</article>
);
}

View file

@ -0,0 +1,242 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from "react";
import { useVerifyCredentialsQuery } from "../lib/query/oauth";
import { MediaAttachment, Status as StatusType } from "../lib/types/status";
import sanitize from "sanitize-html";
export function FakeStatus({ children }) {
const { data: account = {
avatar: "/assets/default_avatars/GoToSocial_icon1.png",
display_name: "",
username: ""
} } = useVerifyCredentialsQuery();
return (
<article className="status expanded">
<header className="status-header">
<address>
<a style={{margin: 0}}>
<img className="avatar" src={account.avatar} alt="" />
<dl className="author-strap">
<dt className="sr-only">Display name</dt>
<dd className="displayname text-cutoff">
{account.display_name.trim().length > 0 ? account.display_name : account.username}
</dd>
<dt className="sr-only">Username</dt>
<dd className="username text-cutoff">@{account.username}</dd>
</dl>
</a>
</address>
</header>
<section className="status-body">
<div className="text">
<div className="content">
{children}
</div>
</div>
</section>
</article>
);
}
export function Status({ status }: { status: StatusType }) {
return (
<article
className="status expanded"
id={status.id}
role="region"
>
<StatusHeader status={status} />
<StatusBody status={status} />
<StatusFooter status={status} />
<a
href={status.url}
target="_blank"
className="status-link"
data-nosnippet
title="Open this status (opens in new tab)"
>
Open this status (opens in new tab)
</a>
</article>
);
}
function StatusHeader({ status }: { status: StatusType }) {
const author = status.account;
return (
<header className="status-header">
<address>
<a
href={author.url}
rel="author"
title="Open profile"
target="_blank"
>
<img
className="avatar"
aria-hidden="true"
src={author.avatar}
alt={`Avatar for ${author.username}`}
title={`Avatar for ${author.username}`}
/>
<div className="author-strap">
<span className="displayname text-cutoff">{author.display_name}</span>
<span className="sr-only">,</span>
<span className="username text-cutoff">@{author.acct}</span>
</div>
<span className="sr-only">(open profile)</span>
</a>
</address>
</header>
);
}
function StatusBody({ status }: { status: StatusType }) {
let content: string;
if (status.content.length === 0) {
content = "[no content set]";
} else {
// HTML has already been through
// the instance sanitizer by now,
// but do it again just in case.
content = sanitize(status.content);
}
return (
<div className="status-body">
<details className="text-spoiler">
<summary>
<span
className="spoiler-text"
lang={status.language}
>
{ status.spoiler_text
? status.spoiler_text + " "
: "[no content warning set] "
}
</span>
<span
className="button"
role="button"
tabIndex={0}
aria-label="Toggle content visibility"
>
Toggle content visibility
</span>
</summary>
<div
className="text"
dangerouslySetInnerHTML={{__html: content}}
/>
</details>
<StatusMedia status={status} />
</div>
);
}
function StatusMedia({ status }: { status: StatusType }) {
if (status.media_attachments.length === 0) {
return null;
}
const count = status.media_attachments.length;
const aria_label = count === 1 ? "1 attachment" : `${count} attachments`;
const oddOrEven = count % 2 === 0 ? "even" : "odd";
const single = count === 1 ? " single" : "";
return (
<div
className={`media ${oddOrEven}${single}`}
role="group"
aria-label={aria_label}
>
{ status.media_attachments.map((media) => {
return (
<StatusMediaEntry
key={media.id}
media={media}
/>
);
})}
</div>
);
}
function StatusMediaEntry({ media }: { media: MediaAttachment }) {
return (
<div className="media-wrapper">
<details className="image-spoiler media-spoiler">
<summary>
<div className="show sensitive button" aria-hidden="true">Show media</div>
<span className="eye button" role="button" tabIndex={0} aria-label="Toggle show media">
<i className="hide fa fa-fw fa-eye-slash" aria-hidden="true"></i>
<i className="show fa fa-fw fa-eye" aria-hidden="true"></i>
</span>
<img
src={media.preview_url}
loading="lazy"
alt={media.description}
title={media.description}
width={media.meta.small.width}
height={media.meta.small.height}
/>
</summary>
<a
href={media.url}
target="_blank"
>
<img
src={media.url}
loading="lazy"
alt={media.description}
width={media.meta.original.width}
height={media.meta.original.height}
/>
</a>
</details>
</div>
);
}
function StatusFooter({ status }: { status: StatusType }) {
return (
<aside className="status-info" aria-hidden="true">
<dl className="status-stats">
<div className="stats-grouping">
<div className="stats-item published-at text-cutoff">
<dt className="sr-only">Published</dt>
<dd>
<time dateTime={status.created_at}>
{ new Date(status.created_at).toLocaleString() }
</time>
</dd>
</div>
</div>
<div className="stats-item language">
<dt className="sr-only">Language</dt>
<dd>{status.language}</dd>
</div>
</dl>
</aside>
);
}

View file

@ -60,7 +60,7 @@ export default function Username({ account, linkTo, backLocation, classNames }:
); );
if (linkTo) { if (linkTo) {
className += " spanlink"; className += " pseudolink";
return ( return (
<span <span
className={className} className={className}

View file

@ -21,29 +21,51 @@ import { gtsApi } from "../../gts-api";
import type { import type {
AdminReport, AdminReport,
AdminReportListParams, AdminSearchReportParams,
AdminReportResolveParams, AdminReportResolveParams,
AdminSearchReportResp,
} from "../../../types/report"; } from "../../../types/report";
import parse from "parse-link-header";
const extended = gtsApi.injectEndpoints({ const extended = gtsApi.injectEndpoints({
endpoints: (build) => ({ endpoints: (build) => ({
listReports: build.query<AdminReport[], AdminReportListParams | void>({ searchReports: build.query<AdminSearchReportResp, AdminSearchReportParams>({
query: (params) => ({ query: (form) => {
url: "/api/v1/admin/reports", const params = new(URLSearchParams);
params: { Object.entries(form).forEach(([k, v]) => {
// Override provided limit. if (v !== undefined) {
limit: 100, params.append(k, v);
...params }
});
let query = "";
if (params.size !== 0) {
query = `?${params.toString()}`;
} }
}),
providesTags: [{ type: "Reports", id: "LIST" }] return {
url: `/api/v1/admin/reports${query}`
};
},
// Headers required for paging.
transformResponse: (apiResp: AdminReport[], meta) => {
const accounts = apiResp;
const linksStr = meta?.response?.headers.get("Link");
const links = parse(linksStr);
return { accounts, links };
},
// Only provide LIST tag id since this model is not the
// same as getReport model (due to transformResponse).
providesTags: [{ type: "Report", id: "TRANSFORMED" }]
}), }),
getReport: build.query<AdminReport, string>({ getReport: build.query<AdminReport, string>({
query: (id) => ({ query: (id) => ({
url: `/api/v1/admin/reports/${id}` url: `/api/v1/admin/reports/${id}`
}), }),
providesTags: (_res, _error, id) => [{ type: "Reports", id }] providesTags: (_result, _error, id) => [
{ type: 'Report', id }
],
}), }),
resolveReport: build.mutation<AdminReport, AdminReportResolveParams>({ resolveReport: build.mutation<AdminReport, AdminReportResolveParams>({
@ -55,8 +77,8 @@ const extended = gtsApi.injectEndpoints({
}), }),
invalidatesTags: (res) => invalidatesTags: (res) =>
res res
? [{ type: "Reports", id: "LIST" }, { type: "Reports", id: res.id }] ? [{ type: "Report", id: "LIST" }, { type: "Report", id: res.id }]
: [{ type: "Reports", id: "LIST" }] : [{ type: "Report", id: "LIST" }]
}) })
}) })
}); });
@ -64,7 +86,7 @@ const extended = gtsApi.injectEndpoints({
/** /**
* List reports received on this instance, filtered using given parameters. * List reports received on this instance, filtered using given parameters.
*/ */
const useListReportsQuery = extended.useListReportsQuery; const useLazySearchReportsQuery = extended.useLazySearchReportsQuery;
/** /**
* Get a single report by its ID. * Get a single report by its ID.
@ -77,7 +99,7 @@ const useGetReportQuery = extended.useGetReportQuery;
const useResolveReportMutation = extended.useResolveReportMutation; const useResolveReportMutation = extended.useResolveReportMutation;
export { export {
useListReportsQuery, useLazySearchReportsQuery,
useGetReportQuery, useGetReportQuery,
useResolveReportMutation, useResolveReportMutation,
}; };

View file

@ -136,7 +136,7 @@ export const gtsApi = createApi({
tagTypes: [ tagTypes: [
"Auth", "Auth",
"Emoji", "Emoji",
"Reports", "Report",
"Account", "Account",
"InstanceRules", "InstanceRules",
"HTTPHeaderAllows", "HTTPHeaderAllows",

View file

@ -17,6 +17,10 @@
along with this program. If not, see <http://www.gnu.org/licenses/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { Links } from "parse-link-header";
import { AdminAccount } from "./account";
import { Status } from "./status";
/** /**
* Admin model of a report. Differs from the client * Admin model of a report. Differs from the client
* model, which contains less detailed information. * model, which contains less detailed information.
@ -56,29 +60,25 @@ export interface AdminReport {
updated_at: string; updated_at: string;
/** /**
* Account that created the report. * Account that created the report.
* TODO: model this properly.
*/ */
account: Object; account: AdminAccount;
/** /**
* Reported account. * Reported account.
* TODO: model this properly.
*/ */
target_account: Object; target_account: AdminAccount;
/** /**
* Admin account assigned to handle this report, if any. * Admin account assigned to handle this report, if any.
* TODO: model this properly.
*/ */
assigned_account?: Object; assigned_account?: AdminAccount;
/** /**
* Admin account that has taken action on this report, if any. * Admin account that has taken action on this report, if any.
* TODO: model this properly.
*/ */
action_taken_by_account?: Object; action_taken_by_account?: AdminAccount;
/** /**
* Statuses cited by this report, if any. * Statuses cited by this report, if any.
* TODO: model this properly. * TODO: model this properly.
*/ */
statuses: Object[]; statuses: Status[];
/** /**
* Rules broken according to the reporter, if any. * Rules broken according to the reporter, if any.
* TODO: model this properly. * TODO: model this properly.
@ -108,7 +108,7 @@ export interface AdminReportResolveParams {
/** /**
* Parameters for GET to /api/v1/admin/reports. * Parameters for GET to /api/v1/admin/reports.
*/ */
export interface AdminReportListParams { export interface AdminSearchReportParams {
/** /**
* If set, show only resolved (true) or only unresolved (false) reports. * If set, show only resolved (true) or only unresolved (false) reports.
*/ */
@ -142,3 +142,8 @@ export interface AdminReportListParams {
*/ */
limit?: number; limit?: number;
} }
export interface AdminSearchReportResp {
accounts: AdminReport[];
links: Links | null;
}

View file

@ -0,0 +1,83 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Account } from "./account";
import { CustomEmoji } from "./custom-emoji";
export interface Status {
id: string;
created_at: string;
in_reply_to_id: string | null;
in_reply_to_account_id: string | null;
sensitive: boolean;
spoiler_text: string;
visibility: string;
language: string;
uri: string;
url: string;
replies_count: number;
reblogs_count: number;
favourites_count: number;
favourited: boolean;
reblogged: boolean;
muted: boolean;
bookmarked: boolean;
pinned: boolean;
content: string,
reblog: Status | null,
account: Account,
media_attachments: MediaAttachment[],
mentions: [];
tags: [];
emojis: CustomEmoji[];
card: null;
poll: null;
}
export interface MediaAttachment {
id: string;
type: string;
url: string;
text_url: string;
preview_url: string;
remote_url: string | null;
preview_remote_url: string | null;
meta: MediaAttachmentMeta;
description: string;
blurhash: string;
}
interface MediaAttachmentMeta {
original: {
width: number;
height: number;
size: string;
aspect: number;
},
small: {
width: number;
height: number;
size: string;
aspect: number;
},
focus: {
x: number;
y: number;
}
}

View file

@ -19,8 +19,8 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { AdminAccount } from "../../../../lib/types/account"; import { AdminAccount } from "../types/account";
import { store } from "../../../../redux/store"; import { store } from "../../redux/store";
export function yesOrNo(b: boolean): string { export function yesOrNo(b: boolean): string {
return b ? "yes" : "no"; return b ? "yes" : "no";

View file

@ -1045,62 +1045,62 @@ button.with-padding {
} }
} }
.reports { .reports-view {
p {
margin: 0;
}
.report { .report {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-wrap: nowrap;
gap: 0.5rem; gap: 0.5rem;
margin: 0.5rem 0;
text-decoration: none;
color: $fg; color: $fg;
padding: 1rem;
border: none;
border-left: 0.3rem solid $border-accent; border-left: 0.3rem solid $border-accent;
.usernames { .username-lozenge {
line-height: 2rem; display: flex;
} flex-wrap: nowrap;
height: 100%;
align-items: center;
padding-top: 0;
padding-bottom: 0;
.byline { .fa {
display: grid; flex-shrink: 0;
grid-template-columns: 1fr auto;
gap: 0.5rem;
.report-status {
color: $border-accent;
} }
} }
.details { .report-byline {
display: grid; max-width: fit-content;
grid-template-columns: auto 1fr;
gap: 0.2rem 0.5rem;
padding: 0.5rem;
justify-items: start;
} }
h3 { .info-list {
margin: 0; border: none;
.info-list-entry {
background: none;
padding: 0;
.report-target .username-lozenge {
color: $bg;
}
.reported-by .username-lozenge {
color: $fg;
font-weight: initial;
border-radius: 0;
background: none;
}
}
} }
&.resolved { &.resolved {
color: $fg-reduced; border-left: 0.3rem solid $list-entry-bg;
border-left: 0.4rem solid $bg;
.byline .report-status { .info-list,
.info-list .info-list-entry .reported-by .username-lozenge {
color: $fg-reduced; color: $fg-reduced;
} }
.user { &:hover {
opacity: 0.8; border-color: $fg-accent;
} }
} }
@ -1109,70 +1109,40 @@ button.with-padding {
padding: 0; padding: 0;
} }
} }
}
.report.detail { .report-detail {
display: flex; .info-list {
flex-direction: column;
margin-top: 1rem;
gap: 1rem;
.info-block { &.overview {
padding: 0.5rem; margin-top: 1rem;
background: $gray2;
} }
.info { .username-lozenge {
display: block; display: flex;
} flex-wrap: nowrap;
height: 100%;
align-items: center;
padding-top: 0;
padding-bottom: 0;
max-width: fit-content;
.reported-toots { .fa {
margin-top: 0.5rem; flex-shrink: 0;
}
.toot .toot-info {
padding: 0.5rem;
background: $toot-info-bg;
a {
color: $fg-reduced;
}
&:last-child {
border-bottom-left-radius: $br;
border-bottom-right-radius: $br;
} }
} }
} }
}
.username-lozenge { .report-statuses {
line-height: 1.3rem; width: min(100%, 50rem);
display: inline-block;
background: $fg-accent;
color: $bg;
border-radius: $br;
padding: 0.15rem;
font-weight: bold;
text-decoration: none;
.acct { .thread {
word-break: break-all; display: flex;
flex-direction: column;
gap: 2rem;
padding: 0;
}
} }
&.suspended {
background: $bg-accent;
color: $fg;
text-decoration: line-through;
}
&.local {
background: $green1;
}
}
.spanlink {
cursor: pointer;
text-decoration: none;
} }
.accounts-view { .accounts-view {
@ -1223,6 +1193,36 @@ button.with-padding {
} }
} }
.username-lozenge {
line-height: 1.3rem;
display: inline-block;
background: $fg-accent;
color: $bg;
border-radius: $br;
padding: 0.15rem;
font-weight: bold;
text-decoration: none;
.acct {
word-break: break-all;
}
&.suspended {
background: $bg-accent;
color: $fg;
text-decoration: line-through;
}
&.local {
background: $green1;
}
}
.pseudolink {
cursor: pointer;
text-decoration: none;
}
.info-list { .info-list {
border: 0.1rem solid $gray1; border: 0.1rem solid $gray1;
display: flex; display: flex;

View file

@ -22,7 +22,7 @@ import { Redirect, useParams } from "wouter";
import { useComboBoxInput, useFileInput, useValue } from "../../../../lib/form"; import { useComboBoxInput, useFileInput, useValue } from "../../../../lib/form";
import useFormSubmit from "../../../../lib/form/submit"; import useFormSubmit from "../../../../lib/form/submit";
import { useBaseUrl } from "../../../../lib/navigation/util"; import { useBaseUrl } from "../../../../lib/navigation/util";
import FakeToot from "../../../../components/fake-toot"; import { FakeStatus } from "../../../../components/status";
import FormWithData from "../../../../lib/form/form-with-data"; import FormWithData from "../../../../lib/form/form-with-data";
import Loading from "../../../../components/loading"; import Loading from "../../../../components/loading";
import { FileInput } from "../../../../components/form/inputs"; import { FileInput } from "../../../../components/form/inputs";
@ -124,14 +124,14 @@ function EmojiDetailForm({ data: emoji }) {
disabled={!form.image.value} disabled={!form.image.value}
/> />
<FakeToot> <FakeStatus>
Look at this new custom emoji <img Look at this new custom emoji <img
className="emoji" className="emoji"
src={form.image.previewValue ?? emoji.url} src={form.image.previewValue ?? emoji.url}
title={`:${emoji.shortcode}:`} title={`:${emoji.shortcode}:`}
alt={emoji.shortcode} alt={emoji.shortcode}
/> isn&apos;t it cool? /> isn&apos;t it cool?
</FakeToot> </FakeStatus>
{result.error && <Error error={result.error} />} {result.error && <Error error={result.error} />}
{deleteResult.error && <Error error={deleteResult.error} />} {deleteResult.error && <Error error={deleteResult.error} />}

View file

@ -23,7 +23,7 @@ import useShortcode from "./use-shortcode";
import useFormSubmit from "../../../../lib/form/submit"; import useFormSubmit from "../../../../lib/form/submit";
import { TextInput, FileInput } from "../../../../components/form/inputs"; import { TextInput, FileInput } from "../../../../components/form/inputs";
import { CategorySelect } from '../category-select'; import { CategorySelect } from '../category-select';
import FakeToot from "../../../../components/fake-toot"; import { FakeStatus } from "../../../../components/status";
import MutationButton from "../../../../components/form/mutation-button"; import MutationButton from "../../../../components/form/mutation-button";
import { useAddEmojiMutation } from "../../../../lib/query/admin/custom-emoji"; import { useAddEmojiMutation } from "../../../../lib/query/admin/custom-emoji";
import { useInstanceV1Query } from "../../../../lib/query/gts-api"; import { useInstanceV1Query } from "../../../../lib/query/gts-api";
@ -103,9 +103,9 @@ export default function NewEmojiForm() {
<div> <div>
<h2>Add new custom emoji</h2> <h2>Add new custom emoji</h2>
<FakeToot> <FakeStatus>
Look at this new custom emoji {emojiOrShortcode} isn&apos;t it cool? Look at this new custom emoji {emojiOrShortcode} isn&apos;t it cool?
</FakeToot> </FakeStatus>
<form onSubmit={submitForm} className="form-flex"> <form onSubmit={submitForm} className="form-flex">
<FileInput <FileInput

View file

@ -69,7 +69,7 @@ export default function HeaderPermsOverview() {
return ( return (
<dl <dl
key={perm.id} key={perm.id}
className="entry spanlink" className="entry pseudolink"
onClick={() => { onClick={() => {
// When clicking on a header perm, // When clicking on a header perm,
// go to the detail view for perm. // go to the detail view for perm.

View file

@ -21,13 +21,13 @@ import React from "react";
import { useGetAccountQuery } from "../../../../lib/query/admin"; import { useGetAccountQuery } from "../../../../lib/query/admin";
import FormWithData from "../../../../lib/form/form-with-data"; import FormWithData from "../../../../lib/form/form-with-data";
import FakeProfile from "../../../../components/fake-profile"; import FakeProfile from "../../../../components/profile";
import { AdminAccount } from "../../../../lib/types/account"; import { AdminAccount } from "../../../../lib/types/account";
import { AccountActions } from "./actions"; import { AccountActions } from "./actions";
import { useParams } from "wouter"; import { useParams } from "wouter";
import { useBaseUrl } from "../../../../lib/navigation/util"; import { useBaseUrl } from "../../../../lib/navigation/util";
import BackButton from "../../../../components/back-button"; import BackButton from "../../../../components/back-button";
import { UseOurInstanceAccount, yesOrNo } from "./util"; import { UseOurInstanceAccount, yesOrNo } from "../../../../lib/util";
export default function AccountDetail() { export default function AccountDetail() {
const params: { accountID: string } = useParams(); const params: { accountID: string } = useParams();

View file

@ -83,7 +83,7 @@ export function AccountSearchForm() {
} }
// Location to return to when user clicks "back" on the account detail view. // Location to return to when user clicks "back" on the account detail view.
const backLocation = location + (urlQueryParams ? `?${urlQueryParams}` : ""); const backLocation = location + (urlQueryParams.size > 0 ? `?${urlQueryParams}` : "");
// Function to map an item to a list entry. // Function to map an item to a list entry.
function itemToEntry(account: AdminAccount): ReactNode { function itemToEntry(account: AdminAccount): ReactNode {

View file

@ -17,8 +17,8 @@
along with this program. If not, see <http://www.gnu.org/licenses/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import React, { useState } from "react"; import React from "react";
import { useParams } from "wouter"; import { useLocation, useParams } from "wouter";
import FormWithData from "../../../lib/form/form-with-data"; import FormWithData from "../../../lib/form/form-with-data";
import BackButton from "../../../components/back-button"; import BackButton from "../../../components/back-button";
import { useValue, useTextInput } from "../../../lib/form"; import { useValue, useTextInput } from "../../../lib/form";
@ -28,84 +28,172 @@ import MutationButton from "../../../components/form/mutation-button";
import Username from "../../../components/username"; import Username from "../../../components/username";
import { useGetReportQuery, useResolveReportMutation } from "../../../lib/query/admin/reports"; import { useGetReportQuery, useResolveReportMutation } from "../../../lib/query/admin/reports";
import { useBaseUrl } from "../../../lib/navigation/util"; import { useBaseUrl } from "../../../lib/navigation/util";
import { AdminReport } from "../../../lib/types/report";
import { yesOrNo } from "../../../lib/util";
import { Status } from "../../../components/status";
export default function ReportDetail({ }) { export default function ReportDetail({ }) {
const params: { reportId: string } = useParams();
const baseUrl = useBaseUrl(); const baseUrl = useBaseUrl();
const params = useParams(); const backLocation: String = history.state?.backLocation ?? `~${baseUrl}`;
return ( return (
<div className="reports"> <div className="report-detail">
<h1><BackButton to={`~${baseUrl}`}/> Report Details</h1> <h1><BackButton to={backLocation}/> Report Details</h1>
<FormWithData <FormWithData
dataQuery={useGetReportQuery} dataQuery={useGetReportQuery}
queryArg={params.reportId} queryArg={params.reportId}
DataForm={ReportDetailForm} DataForm={ReportDetailForm}
{...{ backLocation: backLocation }}
/> />
</div> </div>
); );
} }
function ReportDetailForm({ data: report }) { function ReportDetailForm({ data: report }: { data: AdminReport }) {
const from = report.account; const [ location ] = useLocation();
const target = report.target_account; const baseUrl = useBaseUrl();
return ( return (
<div className="report detail"> <>
<div className="usernames"> <ReportBasicInfo
<Username report={report}
account={from} baseUrl={baseUrl}
linkTo={`~/settings/moderation/accounts/${from.id}`} location={location}
backLocation={`~/settings/moderation/reports/${report.id}`} />
/>
<> reported </>
<Username
account={target}
linkTo={`~/settings/moderation/accounts/${target.id}`}
backLocation={`~/settings/moderation/reports/${report.id}`}
/>
</div>
{report.action_taken && { report.action_taken
<div className="info"> && <ReportHistory
<h3>Resolved by @{report.action_taken_by_account.account.acct}</h3> report={report}
<span className="timestamp">at {new Date(report.action_taken_at).toLocaleString()}</span> baseUrl={baseUrl}
<br /> location={location}
<b>Comment: </b><span>{report.action_taken_comment}</span> />
</div>
} }
<div className="info-block"> { report.statuses &&
<h3>Report info:</h3> <ReportStatuses report={report} />
<div className="details"> }
<b>Created: </b>
<span>{new Date(report.created_at).toLocaleString()}</span>
<b>Forwarded: </b> <span>{report.forwarded ? "Yes" : "No"}</span> { !report.action_taken &&
<b>Category: </b> <span>{report.category}</span> <ReportActionForm report={report} />
}
</>
);
}
<b>Reason: </b> interface ReportSectionProps {
{report.comment.length > 0 report: AdminReport;
? <p>{report.comment}</p> baseUrl: string;
: <i className="no-comment">none provided</i> location: string;
}
function ReportBasicInfo({ report, baseUrl, location }: ReportSectionProps) {
const from = report.account;
const target = report.target_account;
const comment = report.comment;
const status = report.action_taken ? "Resolved" : "Unresolved";
const created = new Date(report.created_at).toLocaleString();
return (
<dl className="info-list overview">
<div className="info-list-entry">
<dt>Reported account</dt>
<dd>
<Username
account={target}
linkTo={`~/settings/moderation/accounts/${target.id}`}
backLocation={`~${baseUrl}${location}`}
/>
</dd>
</div>
<div className="info-list-entry">
<dt>Reported by</dt>
<dd>
<Username
account={from}
linkTo={`~/settings/moderation/accounts/${from.id}`}
backLocation={`~${baseUrl}${location}`}
/>
</dd>
</div>
<div className="info-list-entry">
<dt>Status</dt>
<dd>
{ report.action_taken
? <>{status}</>
: <b>{status}</b>
} }
</dd>
</div>
</div> </div>
{!report.action_taken && <ReportActionForm report={report} />} <div className="info-list-entry">
<dt>Reason</dt>
<dd>
{ comment.length > 0
? <>{comment}</>
: <i>none provided</i>
}
</dd>
</div>
{ <div className="info-list-entry">
report.statuses.length > 0 && <dt>Created</dt>
<div className="info-block"> <dd>
<h3>Reported toots ({report.statuses.length}):</h3> <time dateTime={report.created_at}>{created}</time>
<div className="reported-toots"> </dd>
{report.statuses.map((status) => ( </div>
<ReportedToot key={status.id} toot={status} />
))} <div className="info-list-entry">
</div> <dt>Category</dt>
<dd>{ report.category }</dd>
</div>
<div className="info-list-entry">
<dt>Forwarded</dt>
<dd>{ yesOrNo(report.forwarded) }</dd>
</div>
</dl>
);
}
function ReportHistory({ report, baseUrl, location }: ReportSectionProps) {
const handled_by = report.action_taken_by_account;
if (!handled_by) {
throw "report handled by action_taken_by_account undefined";
}
const handled = report.action_taken_at ? new Date(report.action_taken_at).toLocaleString() : "never";
return (
<>
<h3>Moderation History</h3>
<dl className="info-list">
<div className="info-list-entry">
<dt>Handled by</dt>
<dd>
<Username
account={handled_by}
linkTo={`~/settings/moderation/accounts/${handled_by.id}`}
backLocation={`~${baseUrl}${location}`}
/>
</dd>
</div> </div>
}
</div> <div className="info-list-entry">
<dt>Handled</dt>
<dd>
<time dateTime={report.action_taken_at}>{handled}</time>
</dd>
</div>
<div className="info-list-entry">
<dt>Comment</dt>
<dd>{ report.action_taken_comment ?? "none"}</dd>
</div>
</dl>
</>
); );
} }
@ -118,13 +206,18 @@ function ReportActionForm({ report }) {
const [submit, result] = useFormSubmit(form, useResolveReportMutation(), { changedOnly: false }); const [submit, result] = useFormSubmit(form, useResolveReportMutation(), { changedOnly: false });
return ( return (
<form onSubmit={submit} className="info-block"> <form onSubmit={submit}>
<h3>Resolving this report</h3> <h3>Resolve this report</h3>
<p> <>
An optional comment can be included while resolving this report. An optional comment can be included while resolving this report.
Useful for providing an explanation about what action was taken (if any) before the report was marked as resolved.<br /> This is useful for providing an explanation about what action was
<b>This will be visible to the user that created the report!</b> taken (if any) before the report was marked as resolved.
</p> <br />
<b>
Any comment made here will be visible
to the user that created the report!
</b>
</>
<TextArea <TextArea
field={form.comment} field={form.comment}
label="Comment" label="Comment"
@ -138,116 +231,24 @@ function ReportActionForm({ report }) {
); );
} }
function ReportedToot({ toot }) { function ReportStatuses({ report }: { report: AdminReport }) {
const account = toot.account; if (report.statuses.length === 0) {
return null;
return (
<article className="status expanded">
<header className="status-header">
<address>
<a style={{margin: 0}}>
<img className="avatar" src={account.avatar} alt="" />
<dl className="author-strap">
<dt className="sr-only">Display name</dt>
<dd className="displayname text-cutoff">
{account.display_name.trim().length > 0 ? account.display_name : account.username}
</dd>
<dt className="sr-only">Username</dt>
<dd className="username text-cutoff">@{account.username}</dd>
</dl>
</a>
</address>
</header>
<section className="status-body">
<div className="text">
<div className="content">
{toot.spoiler_text?.length > 0
? <TootCW content={toot.content} note={toot.spoiler_text} />
: toot.content
}
</div>
</div>
{toot.media_attachments?.length > 0 &&
<TootMedia media={toot.media_attachments} sensitive={toot.sensitive} />
}
</section>
<aside className="status-info">
<dl className="status-stats">
<div className="stats-grouping">
<div className="stats-item published-at text-cutoff">
<dt className="sr-only">Published</dt>
<dd>
<time dateTime={toot.created_at}>{new Date(toot.created_at).toLocaleString()}</time>
</dd>
</div>
</div>
</dl>
</aside>
</article>
);
}
function TootCW({ note, content }) {
const [visible, setVisible] = useState(false);
function toggleVisible() {
setVisible(!visible);
} }
return ( return (
<> <div className="report-statuses">
<div className="spoiler"> <h3>Reported Statuses</h3>
<span>{note}</span> <ul className="thread">
<label className="button spoiler-label" onClick={toggleVisible}>Show {visible ? "less" : "more"}</label> { report.statuses.map((status) => {
</div> return (
{visible && content} <Status
</> key={status.id}
); status={status}
} />
);
function TootMedia({ media, sensitive }) { })}
let classes = (media.length % 2 == 0) ? "even" : "odd"; </ul>
if (media.length == 1) {
classes += " single";
}
return (
<div className={`media photoswipe-gallery ${classes}`}>
{media.map((m) => (
<div key={m.id} className="media-wrapper">
{sensitive && <>
<input id={`sensitiveMedia-${m.id}`} type="checkbox" className="sensitive-checkbox hidden" />
<div className="sensitive">
<div className="open">
<label htmlFor={`sensitiveMedia-${m.id}`} className="button" role="button" tabIndex={0}>
<i className="fa fa-eye-slash" title="Hide sensitive media"></i>
</label>
</div>
<div className="closed" title={m.description}>
<label htmlFor={`sensitiveMedia-${m.id}`} className="button" role="button" tabIndex={0}>
Show sensitive media
</label>
</div>
</div>
</>}
<a
href={m.url}
title={m.description}
target="_blank"
rel="noreferrer"
data-cropped="true"
data-pswp-width={`${m.meta?.original.width}px`}
data-pswp-height={`${m.meta?.original.height}px`}
>
<img
alt={m.description}
src={m.url}
// thumb={m.preview_url}
sizes={m.meta?.original}
/>
</a>
</div>
))}
</div> </div>
); );
} }

View file

@ -1,97 +0,0 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from "react";
import { Link } from "wouter";
import FormWithData from "../../../lib/form/form-with-data";
import Username from "../../../components/username";
import { useListReportsQuery } from "../../../lib/query/admin/reports";
export function ReportOverview({ }) {
return (
<FormWithData
dataQuery={useListReportsQuery}
DataForm={ReportsList}
/>
);
}
function ReportsList({ data: reports }) {
return (
<div className="reports">
<div className="form-section-docs">
<h1>Reports</h1>
<p>
Here you can view and resolve reports made to your
instance, originating from local and remote users.
</p>
<a
href="https://docs.gotosocial.org/en/latest/admin/settings/#reports"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about this (opens in a new tab)
</a>
</div>
<div className="list">
{reports.map((report) => (
<ReportEntry key={report.id} report={report} />
))}
</div>
</div>
);
}
function ReportEntry({ report }) {
const from = report.account;
const target = report.target_account;
let comment = report.comment.length > 200
? report.comment.slice(0, 200) + "..."
: report.comment;
return (
<Link
to={`/${report.id}`}
className="nounderline"
>
<div className={`report entry${report.action_taken ? " resolved" : ""}`}>
<div className="byline">
<div className="usernames">
<Username account={from} /> reported <Username account={target} />
</div>
<h3 className="report-status">
{report.action_taken ? "Resolved" : "Open"}
</h3>
</div>
<div className="details">
<b>Created: </b>
<span>{new Date(report.created_at).toLocaleString()}</span>
<b>Reason: </b>
{comment.length > 0
? <p>{comment}</p>
: <i className="no-comment">none provided</i>
}
</div>
</div>
</Link>
);
}

View file

@ -0,0 +1,252 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { ReactNode, useEffect, useMemo } from "react";
import { useLazySearchReportsQuery } from "../../../lib/query/admin/reports";
import { useTextInput } from "../../../lib/form";
import { PageableList } from "../../../components/pageable-list";
import { Select } from "../../../components/form/inputs";
import MutationButton from "../../../components/form/mutation-button";
import { useLocation, useSearch } from "wouter";
import Username from "../../../components/username";
import { AdminReport } from "../../../lib/types/report";
export default function ReportsSearch() {
return (
<div className="reports-view">
<h1>Reports Search</h1>
<span>
You can use the form below to search through reports
created by, or directed towards, accounts on this instance.
</span>
<ReportSearchForm />
</div>
);
}
function ReportSearchForm() {
const [ location, setLocation ] = useLocation();
const search = useSearch();
const urlQueryParams = useMemo(() => new URLSearchParams(search), [search]);
const hasParams = urlQueryParams.size != 0;
const [ searchReports, searchRes ] = useLazySearchReportsQuery();
// Populate search form using values from
// urlQueryParams, to allow paging.
const resolved = useMemo(() => {
const resolvedRaw = urlQueryParams.get("resolved");
if (resolvedRaw !== null) {
return resolvedRaw;
}
}, [urlQueryParams]);
const form = {
resolved: useTextInput("resolved", { defaultValue: resolved }),
account_id: useTextInput("account_id", { defaultValue: urlQueryParams.get("account_id") ?? "" }),
target_account_id: useTextInput("target_account_id", { defaultValue: urlQueryParams.get("target_account_id") ?? "" }),
limit: useTextInput("limit", { defaultValue: urlQueryParams.get("limit") ?? "20" })
};
const setResolved = form.resolved.setter;
// On mount, if urlQueryParams were provided,
// trigger the search. For example, if page
// was accessed at /search?origin=local&limit=20,
// then run a search with origin=local and
// limit=20 and immediately render the results.
//
// If no urlQueryParams set, use the default
// search (just show unresolved reports).
useEffect(() => {
if (hasParams) {
searchReports(Object.fromEntries(urlQueryParams));
} else {
setResolved("false");
setLocation(location + "?resolved=false");
}
}, [
urlQueryParams,
hasParams,
searchReports,
location,
setLocation,
setResolved,
]);
// Rather than triggering the search directly,
// the "submit" button changes the location
// based on form field params, and lets the
// useEffect hook above actually do the search.
function submitQuery(e) {
e.preventDefault();
// Parse query parameters.
const entries = Object.entries(form).map(([k, v]) => {
// Take only defined form fields.
if (v.value === undefined || v.value.length === 0 || v.value === "any") {
return null;
}
return [[k, v.value]];
}).flatMap(kv => {
// Remove any nulls.
return kv || [];
});
const searchParams = new URLSearchParams(entries);
setLocation(location + "?" + searchParams.toString());
}
// Location to return to when user clicks "back" on the detail view.
const backLocation = location + (hasParams ? `?${urlQueryParams}` : "");
// Function to map an item to a list entry.
function itemToEntry(report: AdminReport): ReactNode {
return (
<ReportListEntry
key={report.id}
report={report}
linkTo={`/${report.id}`}
backLocation={backLocation}
/>
);
}
return (
<>
<form
onSubmit={submitQuery}
// Prevent password managers
// trying to fill in fields.
autoComplete="off"
>
<Select
field={form.resolved}
label="Report status"
options={
<>
<option value="false">Unresolved only</option>
<option value="true">Resolved only</option>
<option value="">Any</option>
</>
}
></Select>
<MutationButton
disabled={false}
label={"Search"}
result={searchRes}
/>
</form>
<PageableList
isLoading={searchRes.isLoading}
isFetching={searchRes.isFetching}
isSuccess={searchRes.isSuccess}
items={searchRes.data?.accounts}
itemToEntry={itemToEntry}
isError={searchRes.isError}
error={searchRes.error}
emptyMessage={<b>No reports found that match your query.</b>}
prevNextLinks={searchRes.data?.links}
/>
</>
);
}
interface ReportEntryProps {
report: AdminReport;
linkTo: string;
backLocation: string;
}
function ReportListEntry({ report, linkTo, backLocation }: ReportEntryProps) {
const [ _location, setLocation ] = useLocation();
const from = report.account;
const target = report.target_account;
const comment = report.comment;
const status = report.action_taken ? "Resolved" : "Unresolved";
const created = new Date(report.created_at).toLocaleString();
const title = `${status}. @${target.account.acct} was reported by @${from.account.acct} on ${created}. Reason: "${comment}"`;
return (
<span
className={`pseudolink report entry${report.action_taken ? " resolved" : ""}`}
aria-label={title}
title={title}
onClick={() => {
// When clicking on a report, direct
// to the detail view for that report.
setLocation(linkTo, {
// Store the back location in history so
// the detail view can use it to return to
// this page (including query parameters).
state: { backLocation: backLocation }
});
}}
role="link"
tabIndex={0}
>
<dl className="info-list">
<div className="info-list-entry">
<dt>Reported account:</dt>
<dd className="text-cutoff">
<Username
account={target}
classNames={["text-cutoff report-byline"]}
/>
</dd>
</div>
<div className="info-list-entry">
<dt>Reported by:</dt>
<dd className="text-cutoff reported-by">
<Username account={from} />
</dd>
</div>
<div className="info-list-entry">
<dt>Status:</dt>
<dd className="text-cutoff">
{ report.action_taken
? <>{status}</>
: <b>{status}</b>
}
</dd>
</div>
<div className="info-list-entry">
<dt>Reason:</dt>
<dd className="text-cutoff">
{ comment.length > 0
? <>{comment}</>
: <i>none provided</i>
}
</dd>
</div>
<div className="info-list-entry">
<dt>Created:</dt>
<dd className="text-cutoff">
<time dateTime={report.created_at}>{created}</time>
</dd>
</div>
</dl>
</span>
);
}

View file

@ -20,7 +20,7 @@
import React from "react"; import React from "react";
import { BaseUrlContext, useBaseUrl, useHasPermission } from "../../lib/navigation/util"; import { BaseUrlContext, useBaseUrl, useHasPermission } from "../../lib/navigation/util";
import { Redirect, Route, Router, Switch } from "wouter"; import { Redirect, Route, Router, Switch } from "wouter";
import { ReportOverview } from "./reports/overview"; import ReportsSearch from "./reports/search";
import ReportDetail from "./reports/detail"; import ReportDetail from "./reports/detail";
import { ErrorBoundary } from "../../lib/navigation/error"; import { ErrorBoundary } from "../../lib/navigation/error";
import ImportExport from "./domain-permissions/import-export"; import ImportExport from "./domain-permissions/import-export";
@ -85,8 +85,9 @@ function ModerationReportsRouter() {
<Router base={thisBase}> <Router base={thisBase}>
<ErrorBoundary> <ErrorBoundary>
<Switch> <Switch>
<Route path="/search" component={ReportsSearch}/>
<Route path={"/:reportId"} component={ReportDetail} /> <Route path={"/:reportId"} component={ReportDetail} />
<Route component={ReportOverview}/> <Route><Redirect to="/search"/></Route>
</Switch> </Switch>
</ErrorBoundary> </ErrorBoundary>
</Router> </Router>

View file

@ -39,7 +39,7 @@ import {
} from "../../components/form/inputs"; } from "../../components/form/inputs";
import FormWithData from "../../lib/form/form-with-data"; import FormWithData from "../../lib/form/form-with-data";
import FakeProfile from "../../components/fake-profile"; import FakeProfile from "../../components/profile";
import MutationButton from "../../components/form/mutation-button"; import MutationButton from "../../components/form/mutation-button";
import { useAccountThemesQuery } from "../../lib/query/user"; import { useAccountThemesQuery } from "../../lib/query/user";

View file

@ -1499,6 +1499,13 @@
"@types/scheduler" "*" "@types/scheduler" "*"
csstype "^3.0.2" csstype "^3.0.2"
"@types/sanitize-html@^2.11.0":
version "2.11.0"
resolved "https://registry.yarnpkg.com/@types/sanitize-html/-/sanitize-html-2.11.0.tgz#582d8c72215c0228e3af2be136e40e0b531addf2"
integrity sha512-7oxPGNQHXLHE48r/r/qjn7q0hlrs3kL7oZnGj0Wf/h9tj/6ibFyRkNbsDxaBBZ4XUZ0Dx5LGCyDJ04ytSofacQ==
dependencies:
htmlparser2 "^8.0.0"
"@types/scheduler@*": "@types/scheduler@*":
version "0.16.4" version "0.16.4"
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.4.tgz#fedc3e5b15c26dc18faae96bf1317487cb3658cf" resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.4.tgz#fedc3e5b15c26dc18faae96bf1317487cb3658cf"
@ -3125,11 +3132,41 @@ doctrine@^3.0.0:
dependencies: dependencies:
esutils "^2.0.2" esutils "^2.0.2"
dom-serializer@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53"
integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==
dependencies:
domelementtype "^2.3.0"
domhandler "^5.0.2"
entities "^4.2.0"
domain-browser@^1.2.0: domain-browser@^1.2.0:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda" resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda"
integrity sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA== integrity sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA==
domelementtype@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d"
integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==
domhandler@^5.0.2, domhandler@^5.0.3:
version "5.0.3"
resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31"
integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==
dependencies:
domelementtype "^2.3.0"
domutils@^3.0.1:
version "3.1.0"
resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.1.0.tgz#c47f551278d3dc4b0b1ab8cbb42d751a6f0d824e"
integrity sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==
dependencies:
dom-serializer "^2.0.0"
domelementtype "^2.3.0"
domhandler "^5.0.3"
drange@^1.0.2: drange@^1.0.2:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/drange/-/drange-1.1.1.tgz#b2aecec2aab82fcef11dbbd7b9e32b83f8f6c0b8" resolved "https://registry.yarnpkg.com/drange/-/drange-1.1.1.tgz#b2aecec2aab82fcef11dbbd7b9e32b83f8f6c0b8"
@ -3198,6 +3235,11 @@ enhanced-resolve@^5.0.0:
graceful-fs "^4.2.4" graceful-fs "^4.2.4"
tapable "^2.2.0" tapable "^2.2.0"
entities@^4.2.0, entities@^4.4.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48"
integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==
error-ex@^1.2.0: error-ex@^1.2.0:
version "1.3.2" version "1.3.2"
resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf"
@ -4041,6 +4083,16 @@ htmlescape@^1.1.0:
resolved "https://registry.yarnpkg.com/htmlescape/-/htmlescape-1.1.1.tgz#3a03edc2214bca3b66424a3e7959349509cb0351" resolved "https://registry.yarnpkg.com/htmlescape/-/htmlescape-1.1.1.tgz#3a03edc2214bca3b66424a3e7959349509cb0351"
integrity sha512-eVcrzgbR4tim7c7soKQKtxa/kQM4TzjnlU83rcZ9bHU6t31ehfV7SktN6McWgwPWg+JYMA/O3qpGxBvFq1z2Jg== integrity sha512-eVcrzgbR4tim7c7soKQKtxa/kQM4TzjnlU83rcZ9bHU6t31ehfV7SktN6McWgwPWg+JYMA/O3qpGxBvFq1z2Jg==
htmlparser2@^8.0.0:
version "8.0.2"
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-8.0.2.tgz#f002151705b383e62433b5cf466f5b716edaec21"
integrity sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==
dependencies:
domelementtype "^2.3.0"
domhandler "^5.0.3"
domutils "^3.0.1"
entities "^4.4.0"
http-errors@2.0.0: http-errors@2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3"
@ -4944,6 +4996,11 @@ nanoid@^3.3.6:
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c"
integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA== integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==
nanoid@^3.3.7:
version "3.3.7"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8"
integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==
nanoid@^4.0.0: nanoid@^4.0.0:
version "4.0.2" version "4.0.2"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-4.0.2.tgz#140b3c5003959adbebf521c170f282c5e7f9fb9e" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-4.0.2.tgz#140b3c5003959adbebf521c170f282c5e7f9fb9e"
@ -5199,6 +5256,11 @@ parse-ms@^2.1.0:
resolved "https://registry.yarnpkg.com/parse-ms/-/parse-ms-2.1.0.tgz#348565a753d4391fa524029956b172cb7753097d" resolved "https://registry.yarnpkg.com/parse-ms/-/parse-ms-2.1.0.tgz#348565a753d4391fa524029956b172cb7753097d"
integrity sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA== integrity sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==
parse-srcset@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/parse-srcset/-/parse-srcset-1.0.2.tgz#f2bd221f6cc970a938d88556abc589caaaa2bde1"
integrity sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==
parseurl@~1.3.3: parseurl@~1.3.3:
version "1.3.3" version "1.3.3"
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
@ -5353,6 +5415,15 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.1.0, postcss-value-parser@^
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
postcss@^8.3.11:
version "8.4.38"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.38.tgz#b387d533baf2054288e337066d81c6bee9db9e0e"
integrity sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==
dependencies:
nanoid "^3.3.7"
picocolors "^1.0.0"
source-map-js "^1.2.0"
postcss@^8.4.12, postcss@^8.4.18: postcss@^8.4.12, postcss@^8.4.18:
version "8.4.31" version "8.4.31"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d"
@ -5863,6 +5934,18 @@ safe-regex-test@^1.0.0:
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
sanitize-html@^2.13.0:
version "2.13.0"
resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-2.13.0.tgz#71aedcdb777897985a4ea1877bf4f895a1170dae"
integrity sha512-Xff91Z+4Mz5QiNSLdLWwjgBDm5b1RU6xBT0+12rapjiaR7SwfRdjw8f+6Rir2MXKLrDicRFHdb51hGOAxmsUIA==
dependencies:
deepmerge "^4.2.2"
escape-string-regexp "^4.0.0"
htmlparser2 "^8.0.0"
is-plain-object "^5.0.0"
parse-srcset "^1.0.2"
postcss "^8.3.11"
scheduler@^0.23.0: scheduler@^0.23.0:
version "0.23.0" version "0.23.0"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe" resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe"
@ -6058,6 +6141,11 @@ source-map-js@^1.0.2:
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
source-map-js@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.0.tgz#16b809c162517b5b8c3e7dcd315a2a5c2612b2af"
integrity sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==
source-map-loader@^4.0.1: source-map-loader@^4.0.1:
version "4.0.1" version "4.0.1"
resolved "https://registry.yarnpkg.com/source-map-loader/-/source-map-loader-4.0.1.tgz#72f00d05f5d1f90f80974eda781cbd7107c125f2" resolved "https://registry.yarnpkg.com/source-map-loader/-/source-map-loader-4.0.1.tgz#72f00d05f5d1f90f80974eda781cbd7107c125f2"