mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2024-11-21 16:55:38 +03:00
[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:
parent
b08c1bd0cb
commit
d2b3d37724
56 changed files with 1389 additions and 726 deletions
|
@ -20,12 +20,14 @@ Instance moderation settings.
|
|||
|
||||
### 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).
|
||||
|
||||
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.
|
||||
|
||||
### Accounts
|
||||
|
|
|
@ -4525,6 +4525,8 @@ paths:
|
|||
- default: 50
|
||||
description: Number of emojis to return. Less than 1, or not set, means unlimited (all emojis).
|
||||
in: query
|
||||
maximum: 200
|
||||
minimum: 0
|
||||
name: limit
|
||||
type: integer
|
||||
- description: |-
|
||||
|
@ -5739,21 +5741,23 @@ paths:
|
|||
in: query
|
||||
name: target_account_id
|
||||
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
|
||||
name: max_id
|
||||
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
|
||||
name: since_id
|
||||
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
|
||||
name: min_id
|
||||
type: string
|
||||
- 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
|
||||
maximum: 100
|
||||
minimum: 1
|
||||
name: limit
|
||||
type: integer
|
||||
produces:
|
||||
|
@ -7707,21 +7711,23 @@ paths:
|
|||
in: query
|
||||
name: target_account_id
|
||||
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
|
||||
name: max_id
|
||||
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
|
||||
name: since_id
|
||||
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
|
||||
name: min_id
|
||||
type: string
|
||||
- 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
|
||||
maximum: 100
|
||||
minimum: 1
|
||||
name: limit
|
||||
type: integer
|
||||
produces:
|
||||
|
|
BIN
docs/assets/admin-settings-report-detail.png
Normal file
BIN
docs/assets/admin-settings-report-detail.png
Normal file
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 |
|
@ -116,10 +116,9 @@ func (m *Module) AccountActionPOSTHandler(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
targetAcctID := c.Param(IDKey)
|
||||
if targetAcctID == "" {
|
||||
err := errors.New("no account id specified")
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
targetAcctID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
form.TargetID = targetAcctID
|
||||
|
|
|
@ -22,6 +22,7 @@ import (
|
|||
|
||||
"codeberg.org/gruf/go-debug"
|
||||
"github.com/gin-gonic/gin"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
)
|
||||
|
@ -29,48 +30,40 @@ import (
|
|||
const (
|
||||
BasePath = "/v1/admin"
|
||||
EmojiPath = BasePath + "/custom_emojis"
|
||||
EmojiPathWithID = EmojiPath + "/:" + IDKey
|
||||
EmojiPathWithID = EmojiPath + "/:" + apiutil.IDKey
|
||||
EmojiCategoriesPath = EmojiPath + "/categories"
|
||||
DomainBlocksPath = BasePath + "/domain_blocks"
|
||||
DomainBlocksPathWithID = DomainBlocksPath + "/:" + IDKey
|
||||
DomainBlocksPathWithID = DomainBlocksPath + "/:" + apiutil.IDKey
|
||||
DomainAllowsPath = BasePath + "/domain_allows"
|
||||
DomainAllowsPathWithID = DomainAllowsPath + "/:" + IDKey
|
||||
DomainAllowsPathWithID = DomainAllowsPath + "/:" + apiutil.IDKey
|
||||
DomainKeysExpirePath = BasePath + "/domain_keys_expire"
|
||||
HeaderAllowsPath = BasePath + "/header_allows"
|
||||
HeaderAllowsPathWithID = HeaderAllowsPath + "/:" + IDKey
|
||||
HeaderAllowsPathWithID = HeaderAllowsPath + "/:" + apiutil.IDKey
|
||||
HeaderBlocksPath = BasePath + "/header_blocks"
|
||||
HeaderBlocksPathWithID = HeaderBlocksPath + "/:" + IDKey
|
||||
HeaderBlocksPathWithID = HeaderBlocksPath + "/:" + apiutil.IDKey
|
||||
AccountsV1Path = BasePath + "/accounts"
|
||||
AccountsV2Path = "/v2/admin/accounts"
|
||||
AccountsPathWithID = AccountsV1Path + "/:" + IDKey
|
||||
AccountsPathWithID = AccountsV1Path + "/:" + apiutil.IDKey
|
||||
AccountsActionPath = AccountsPathWithID + "/action"
|
||||
AccountsApprovePath = AccountsPathWithID + "/approve"
|
||||
AccountsRejectPath = AccountsPathWithID + "/reject"
|
||||
MediaCleanupPath = BasePath + "/media_cleanup"
|
||||
MediaRefetchPath = BasePath + "/media_refetch"
|
||||
ReportsPath = BasePath + "/reports"
|
||||
ReportsPathWithID = ReportsPath + "/:" + IDKey
|
||||
ReportsPathWithID = ReportsPath + "/:" + apiutil.IDKey
|
||||
ReportsResolvePath = ReportsPathWithID + "/resolve"
|
||||
EmailPath = BasePath + "/email"
|
||||
EmailTestPath = EmailPath + "/test"
|
||||
InstanceRulesPath = BasePath + "/instance/rules"
|
||||
InstanceRulesPathWithID = InstanceRulesPath + "/:" + IDKey
|
||||
InstanceRulesPathWithID = InstanceRulesPath + "/:" + apiutil.IDKey
|
||||
DebugPath = BasePath + "/debug"
|
||||
DebugAPUrlPath = DebugPath + "/apurl"
|
||||
DebugClearCachesPath = DebugPath + "/caches/clear"
|
||||
|
||||
IDKey = "id"
|
||||
FilterQueryKey = "filter"
|
||||
MaxShortcodeDomainKey = "max_shortcode_domain"
|
||||
MinShortcodeDomainKey = "min_shortcode_domain"
|
||||
LimitKey = "limit"
|
||||
DomainQueryKey = "domain"
|
||||
ResolvedKey = "resolved"
|
||||
AccountIDKey = "account_id"
|
||||
TargetAccountIDKey = "target_account_id"
|
||||
MaxIDKey = "max_id"
|
||||
SinceIDKey = "since_id"
|
||||
MinIDKey = "min_id"
|
||||
)
|
||||
|
||||
type Module struct {
|
||||
|
|
|
@ -18,7 +18,6 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
|
@ -97,10 +96,9 @@ func (m *Module) EmojiDELETEHandler(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
emojiID := c.Param(IDKey)
|
||||
if emojiID == "" {
|
||||
err := errors.New("no emoji id specified")
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
emojiID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -28,6 +28,7 @@ import (
|
|||
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/admin"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
)
|
||||
|
||||
|
@ -41,7 +42,7 @@ func (suite *EmojiDeleteTestSuite) TestEmojiDelete1() {
|
|||
|
||||
path := admin.EmojiPathWithID
|
||||
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.Equal(http.StatusOK, recorder.Code)
|
||||
|
@ -78,7 +79,7 @@ func (suite *EmojiDeleteTestSuite) TestEmojiDelete2() {
|
|||
|
||||
path := admin.EmojiPathWithID
|
||||
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.Equal(http.StatusBadRequest, recorder.Code)
|
||||
|
@ -100,7 +101,7 @@ func (suite *EmojiDeleteTestSuite) TestEmojiDeleteNotFound() {
|
|||
|
||||
path := admin.EmojiPathWithID
|
||||
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.Equal(http.StatusNotFound, recorder.Code)
|
||||
|
|
|
@ -18,7 +18,6 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
|
@ -82,10 +81,9 @@ func (m *Module) EmojiGETHandler(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
emojiID := c.Param(IDKey)
|
||||
if emojiID == "" {
|
||||
err := errors.New("no emoji id specified")
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
emojiID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -27,6 +27,7 @@ import (
|
|||
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/admin"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
)
|
||||
|
||||
type EmojiGetTestSuite struct {
|
||||
|
@ -39,7 +40,7 @@ func (suite *EmojiGetTestSuite) TestEmojiGet1() {
|
|||
|
||||
path := admin.EmojiPathWithID
|
||||
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.Equal(http.StatusOK, recorder.Code)
|
||||
|
@ -71,7 +72,7 @@ func (suite *EmojiGetTestSuite) TestEmojiGet2() {
|
|||
|
||||
path := admin.EmojiPathWithID
|
||||
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.Equal(http.StatusOK, recorder.Code)
|
||||
|
@ -102,7 +103,7 @@ func (suite *EmojiGetTestSuite) TestEmojiGetNotFound() {
|
|||
|
||||
path := admin.EmojiPathWithID
|
||||
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.Equal(http.StatusNotFound, recorder.Code)
|
||||
|
|
|
@ -20,7 +20,6 @@ package admin
|
|||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
@ -76,6 +75,8 @@ import (
|
|||
// type: integer
|
||||
// description: Number of emojis to return. Less than 1, or not set, means unlimited (all emojis).
|
||||
// default: 50
|
||||
// minimum: 0
|
||||
// maximum: 200
|
||||
// in: query
|
||||
// -
|
||||
// name: max_shortcode_domain
|
||||
|
@ -142,19 +143,10 @@ func (m *Module) EmojisGETHandler(c *gin.Context) {
|
|||
maxShortcodeDomain := c.Query(MaxShortcodeDomainKey)
|
||||
minShortcodeDomain := c.Query(MinShortcodeDomainKey)
|
||||
|
||||
limit := 50
|
||||
limitString := c.Query(LimitKey)
|
||||
if limitString != "" {
|
||||
i, err := strconv.ParseInt(limitString, 10, 32)
|
||||
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
|
||||
limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 50, 200, 0)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
var domain string
|
||||
|
|
|
@ -147,10 +147,9 @@ func (m *Module) EmojiPATCHHandler(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
emojiID := c.Param(IDKey)
|
||||
if emojiID == "" {
|
||||
err := errors.New("no emoji id specified")
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
emojiID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -28,6 +28,7 @@ import (
|
|||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/admin"
|
||||
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/testrig"
|
||||
)
|
||||
|
@ -53,7 +54,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateNewCategory() {
|
|||
bodyBytes := requestBody.Bytes()
|
||||
recorder := httptest.NewRecorder()
|
||||
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
|
||||
suite.adminModule.EmojiPATCHHandler(ctx)
|
||||
|
@ -130,7 +131,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateSwitchCategory() {
|
|||
bodyBytes := requestBody.Bytes()
|
||||
recorder := httptest.NewRecorder()
|
||||
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
|
||||
suite.adminModule.EmojiPATCHHandler(ctx)
|
||||
|
@ -208,7 +209,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyRemoteToLocal() {
|
|||
bodyBytes := requestBody.Bytes()
|
||||
recorder := httptest.NewRecorder()
|
||||
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
|
||||
suite.adminModule.EmojiPATCHHandler(ctx)
|
||||
|
@ -284,7 +285,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateDisableEmoji() {
|
|||
bodyBytes := requestBody.Bytes()
|
||||
recorder := httptest.NewRecorder()
|
||||
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
|
||||
suite.adminModule.EmojiPATCHHandler(ctx)
|
||||
|
@ -325,7 +326,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateDisableLocalEmoji() {
|
|||
bodyBytes := requestBody.Bytes()
|
||||
recorder := httptest.NewRecorder()
|
||||
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
|
||||
suite.adminModule.EmojiPATCHHandler(ctx)
|
||||
|
@ -358,7 +359,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateModifyRemoteEmoji() {
|
|||
bodyBytes := requestBody.Bytes()
|
||||
recorder := httptest.NewRecorder()
|
||||
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
|
||||
suite.adminModule.EmojiPATCHHandler(ctx)
|
||||
|
@ -391,7 +392,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateModifyNoParams() {
|
|||
bodyBytes := requestBody.Bytes()
|
||||
recorder := httptest.NewRecorder()
|
||||
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
|
||||
suite.adminModule.EmojiPATCHHandler(ctx)
|
||||
|
@ -425,7 +426,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyLocalToLocal() {
|
|||
bodyBytes := requestBody.Bytes()
|
||||
recorder := httptest.NewRecorder()
|
||||
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
|
||||
suite.adminModule.EmojiPATCHHandler(ctx)
|
||||
|
@ -459,7 +460,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyEmptyShortcode() {
|
|||
bodyBytes := requestBody.Bytes()
|
||||
recorder := httptest.NewRecorder()
|
||||
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
|
||||
suite.adminModule.EmojiPATCHHandler(ctx)
|
||||
|
@ -492,7 +493,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyNoShortcode() {
|
|||
bodyBytes := requestBody.Bytes()
|
||||
recorder := httptest.NewRecorder()
|
||||
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
|
||||
suite.adminModule.EmojiPATCHHandler(ctx)
|
||||
|
@ -526,7 +527,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyShortcodeAlreadyInUse() {
|
|||
bodyBytes := requestBody.Bytes()
|
||||
recorder := httptest.NewRecorder()
|
||||
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
|
||||
suite.adminModule.EmojiPATCHHandler(ctx)
|
||||
|
|
|
@ -18,7 +18,6 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
|
@ -85,10 +84,9 @@ func (m *Module) ReportGETHandler(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
reportID := c.Param(IDKey)
|
||||
if reportID == "" {
|
||||
err := errors.New("no report id specified")
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
reportID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -18,7 +18,6 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
|
@ -107,10 +106,9 @@ func (m *Module) ReportResolvePOSTHandler(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
reportID := c.Param(IDKey)
|
||||
if reportID == "" {
|
||||
err := errors.New("no report id specified")
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
reportID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -29,6 +29,7 @@ import (
|
|||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/admin"
|
||||
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/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
|
@ -65,7 +66,7 @@ func (suite *ReportResolveTestSuite) resolveReport(
|
|||
|
||||
// create the request
|
||||
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")
|
||||
if actionTakenComment != nil {
|
||||
ctx.Request.Form = url.Values{"action_taken_comment": {*actionTakenComment}}
|
||||
|
|
|
@ -20,12 +20,12 @@ package admin
|
|||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||
)
|
||||
|
||||
// ReportsGETHandler swagger:operation GET /api/v1/admin/reports adminReports
|
||||
|
@ -72,7 +72,7 @@ import (
|
|||
// name: max_id
|
||||
// type: string
|
||||
// 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.
|
||||
// in: query
|
||||
// -
|
||||
|
@ -81,23 +81,21 @@ import (
|
|||
// 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.
|
||||
// in: query
|
||||
// -
|
||||
// name: min_id
|
||||
// type: string
|
||||
// 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.
|
||||
// This parameter is functionally equivalent to since_id.
|
||||
// in: query
|
||||
// -
|
||||
// name: limit
|
||||
// type: integer
|
||||
// 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.
|
||||
// default: 20
|
||||
// minimum: 1
|
||||
// maximum: 100
|
||||
// in: query
|
||||
//
|
||||
// security:
|
||||
|
@ -144,34 +142,30 @@ func (m *Module) ReportsGETHandler(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
var resolved *bool
|
||||
if resolvedString := c.Query(ResolvedKey); resolvedString != "" {
|
||||
i, err := strconv.ParseBool(resolvedString)
|
||||
if err != nil {
|
||||
err := fmt.Errorf("error parsing %s: %s", ResolvedKey, err)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
resolved = &i
|
||||
resolved, errWithCode := apiutil.ParseResolved(c.Query(apiutil.ResolvedKey), nil)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
limit := 20
|
||||
if limitString := c.Query(LimitKey); limitString != "" {
|
||||
i, err := strconv.Atoi(limitString)
|
||||
if err != nil {
|
||||
err := fmt.Errorf("error parsing %s: %s", LimitKey, err)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
// normalize
|
||||
if i < 1 || i > 100 {
|
||||
i = 100
|
||||
}
|
||||
limit = i
|
||||
page, errWithCode := paging.ParseIDPage(c,
|
||||
1, // min limit
|
||||
100, // max limit
|
||||
20, // default limit
|
||||
)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
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 {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
|
|
|
@ -28,6 +28,7 @@ import (
|
|||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/admin"
|
||||
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/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
|
@ -63,24 +64,24 @@ func (suite *ReportsGetTestSuite) getReports(
|
|||
ctx.Set(oauth.SessionAuthorizedUser, user)
|
||||
|
||||
// create the request URI
|
||||
requestPath := admin.ReportsPath + "?" + admin.LimitKey + "=" + strconv.Itoa(limit)
|
||||
requestPath := admin.ReportsPath + "?" + apiutil.LimitKey + "=" + strconv.Itoa(limit)
|
||||
if resolved != nil {
|
||||
requestPath = requestPath + "&" + admin.ResolvedKey + "=" + strconv.FormatBool(*resolved)
|
||||
requestPath = requestPath + "&" + apiutil.ResolvedKey + "=" + strconv.FormatBool(*resolved)
|
||||
}
|
||||
if accountID != "" {
|
||||
requestPath = requestPath + "&" + admin.AccountIDKey + "=" + accountID
|
||||
requestPath = requestPath + "&" + apiutil.AccountIDKey + "=" + accountID
|
||||
}
|
||||
if targetAccountID != "" {
|
||||
requestPath = requestPath + "&" + admin.TargetAccountIDKey + "=" + targetAccountID
|
||||
requestPath = requestPath + "&" + apiutil.TargetAccountIDKey + "=" + targetAccountID
|
||||
}
|
||||
if maxID != "" {
|
||||
requestPath = requestPath + "&" + admin.MaxIDKey + "=" + maxID
|
||||
requestPath = requestPath + "&" + apiutil.MaxIDKey + "=" + maxID
|
||||
}
|
||||
if sinceID != "" {
|
||||
requestPath = requestPath + "&" + admin.SinceIDKey + "=" + sinceID
|
||||
requestPath = requestPath + "&" + apiutil.SinceIDKey + "=" + sinceID
|
||||
}
|
||||
if minID != "" {
|
||||
requestPath = requestPath + "&" + admin.MinIDKey + "=" + minID
|
||||
requestPath = requestPath + "&" + apiutil.MinIDKey + "=" + minID
|
||||
}
|
||||
baseURI := config.GetProtocol() + "://" + config.GetHost()
|
||||
requestURI := baseURI + "/api/" + requestPath
|
||||
|
@ -766,7 +767,7 @@ func (suite *ReportsGetTestSuite) TestReportsGetCreatedByAccount() {
|
|||
}
|
||||
]`, 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() {
|
||||
|
@ -1028,8 +1029,8 @@ func (suite *ReportsGetTestSuite) TestReportsGetZeroLimit() {
|
|||
suite.NoError(err)
|
||||
suite.Len(reports, 2)
|
||||
|
||||
// Limit in Link header should be set to 100
|
||||
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)
|
||||
// Limit in Link header should be set to default (20)
|
||||
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() {
|
||||
|
|
|
@ -18,7 +18,6 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
|
@ -95,10 +94,9 @@ func (m *Module) RuleDELETEHandler(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
ruleID := c.Param(IDKey)
|
||||
if ruleID == "" {
|
||||
err := errors.New("no rule id specified")
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
ruleID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -18,7 +18,6 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
|
@ -85,10 +84,9 @@ func (m *Module) RuleGETHandler(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
ruleID := c.Param(IDKey)
|
||||
if ruleID == "" {
|
||||
err := errors.New("no rule id specified")
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
ruleID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -18,7 +18,6 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
|
@ -87,10 +86,9 @@ func (m *Module) RulePATCHHandler(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
ruleID := c.Param(IDKey)
|
||||
if ruleID == "" {
|
||||
err := errors.New("no rule id specified")
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
ruleID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -18,7 +18,6 @@
|
|||
package reports
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
@ -77,10 +76,9 @@ func (m *Module) ReportGETHandler(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
targetReportID := c.Param(IDKey)
|
||||
if targetReportID == "" {
|
||||
err := errors.New("no report id specified")
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
targetReportID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -145,7 +145,7 @@ func (suite *ReportGetTestSuite) TestGetReport2() {
|
|||
}
|
||||
|
||||
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.Nil(report)
|
||||
}
|
||||
|
|
|
@ -21,19 +21,13 @@ import (
|
|||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
||||
)
|
||||
|
||||
const (
|
||||
BasePath = "/v1/reports"
|
||||
IDKey = "id"
|
||||
ResolvedKey = "resolved"
|
||||
TargetAccountIDKey = "target_account_id"
|
||||
MaxIDKey = "max_id"
|
||||
SinceIDKey = "since_id"
|
||||
MinIDKey = "min_id"
|
||||
LimitKey = "limit"
|
||||
BasePathWithID = BasePath + "/:" + IDKey
|
||||
BasePath = "/v1/reports"
|
||||
BasePathWithID = BasePath + "/:" + apiutil.IDKey
|
||||
)
|
||||
|
||||
type Module struct {
|
||||
|
|
|
@ -18,14 +18,13 @@
|
|||
package reports
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||
)
|
||||
|
||||
// ReportsGETHandler swagger:operation GET /api/v1/reports reports
|
||||
|
@ -67,7 +66,7 @@ import (
|
|||
// name: max_id
|
||||
// type: string
|
||||
// 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.
|
||||
// in: query
|
||||
// -
|
||||
|
@ -76,24 +75,21 @@ import (
|
|||
// 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.
|
||||
// in: query
|
||||
// -
|
||||
// name: min_id
|
||||
// type: string
|
||||
// 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.
|
||||
// This parameter is functionally equivalent to since_id.
|
||||
// in: query
|
||||
// -
|
||||
// name: limit
|
||||
// type: integer
|
||||
// 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.
|
||||
// default: 20
|
||||
// minimum: 1
|
||||
// maximum: 100
|
||||
// in: query
|
||||
//
|
||||
// security:
|
||||
|
@ -134,36 +130,29 @@ func (m *Module) ReportsGETHandler(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
var resolved *bool
|
||||
if resolvedString := c.Query(ResolvedKey); resolvedString != "" {
|
||||
i, err := strconv.ParseBool(resolvedString)
|
||||
if err != nil {
|
||||
err := fmt.Errorf("error parsing %s: %s", ResolvedKey, err)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
resolved = &i
|
||||
resolved, errWithCode := apiutil.ParseResolved(c.Query(apiutil.ResolvedKey), nil)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
limit := 20
|
||||
if limitString := c.Query(LimitKey); limitString != "" {
|
||||
i, err := strconv.Atoi(limitString)
|
||||
if err != nil {
|
||||
err := fmt.Errorf("error parsing %s: %s", LimitKey, err)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
// normalize
|
||||
if i <= 0 {
|
||||
i = 1
|
||||
} else if i >= 100 {
|
||||
i = 100
|
||||
}
|
||||
limit = i
|
||||
page, errWithCode := paging.ParseIDPage(c,
|
||||
1, // min limit
|
||||
100, // max limit
|
||||
20, // default limit
|
||||
)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
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 {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
|
|
|
@ -29,6 +29,7 @@ import (
|
|||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/reports"
|
||||
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/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
|
@ -61,21 +62,21 @@ func (suite *ReportsGetTestSuite) getReports(
|
|||
ctx.Set(oauth.SessionAuthorizedUser, user)
|
||||
|
||||
// create the request URI
|
||||
requestPath := reports.BasePath + "?" + reports.LimitKey + "=" + strconv.Itoa(limit)
|
||||
requestPath := reports.BasePath + "?" + apiutil.LimitKey + "=" + strconv.Itoa(limit)
|
||||
if resolved != nil {
|
||||
requestPath = requestPath + "&" + reports.ResolvedKey + "=" + strconv.FormatBool(*resolved)
|
||||
requestPath = requestPath + "&" + apiutil.ResolvedKey + "=" + strconv.FormatBool(*resolved)
|
||||
}
|
||||
if targetAccountID != "" {
|
||||
requestPath = requestPath + "&" + reports.TargetAccountIDKey + "=" + targetAccountID
|
||||
requestPath = requestPath + "&" + apiutil.TargetAccountIDKey + "=" + targetAccountID
|
||||
}
|
||||
if maxID != "" {
|
||||
requestPath = requestPath + "&" + reports.MaxIDKey + "=" + maxID
|
||||
requestPath = requestPath + "&" + apiutil.MaxIDKey + "=" + maxID
|
||||
}
|
||||
if sinceID != "" {
|
||||
requestPath = requestPath + "&" + reports.SinceIDKey + "=" + sinceID
|
||||
requestPath = requestPath + "&" + apiutil.SinceIDKey + "=" + sinceID
|
||||
}
|
||||
if minID != "" {
|
||||
requestPath = requestPath + "&" + reports.MinIDKey + "=" + minID
|
||||
requestPath = requestPath + "&" + apiutil.MinIDKey + "=" + minID
|
||||
}
|
||||
baseURI := config.GetProtocol() + "://" + config.GetHost()
|
||||
requestURI := baseURI + "/api/" + requestPath
|
||||
|
|
|
@ -247,7 +247,7 @@ func (m *Module) SearchGETHandler(c *gin.Context) {
|
|||
Resolve: resolve,
|
||||
Following: following,
|
||||
ExcludeUnreviewed: excludeUnreviewed,
|
||||
AccountID: c.Query(apiutil.SearchAccountIDKey),
|
||||
AccountID: c.Query(apiutil.AccountIDKey),
|
||||
APIv1: apiVersion == apiutil.APIv1,
|
||||
}
|
||||
|
||||
|
|
|
@ -105,7 +105,7 @@ func (suite *SearchGetTestSuite) getSearch(
|
|||
}
|
||||
|
||||
if fromAccountID != nil {
|
||||
queryParts = append(queryParts, apiutil.SearchAccountIDKey+"="+url.QueryEscape(*fromAccountID))
|
||||
queryParts = append(queryParts, apiutil.AccountIDKey+"="+url.QueryEscape(*fromAccountID))
|
||||
}
|
||||
|
||||
requestURL.RawQuery = strings.Join(queryParts, "&")
|
||||
|
|
|
@ -34,13 +34,16 @@ const (
|
|||
|
||||
/* Common keys */
|
||||
|
||||
IDKey = "id"
|
||||
LimitKey = "limit"
|
||||
LocalKey = "local"
|
||||
MaxIDKey = "max_id"
|
||||
SinceIDKey = "since_id"
|
||||
MinIDKey = "min_id"
|
||||
UsernameKey = "username"
|
||||
IDKey = "id"
|
||||
LimitKey = "limit"
|
||||
LocalKey = "local"
|
||||
MaxIDKey = "max_id"
|
||||
SinceIDKey = "since_id"
|
||||
MinIDKey = "min_id"
|
||||
UsernameKey = "username"
|
||||
AccountIDKey = "account_id"
|
||||
TargetAccountIDKey = "target_account_id"
|
||||
ResolvedKey = "resolved"
|
||||
|
||||
/* AP endpoint keys */
|
||||
|
||||
|
@ -55,7 +58,6 @@ const (
|
|||
SearchQueryKey = "q"
|
||||
SearchResolveKey = "resolve"
|
||||
SearchTypeKey = "type"
|
||||
SearchAccountIDKey = "account_id"
|
||||
|
||||
/* Tag keys */
|
||||
|
||||
|
@ -132,6 +134,10 @@ func ParseLocal(value string, defaultValue bool) (bool, gtserror.WithCode) {
|
|||
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) {
|
||||
return parseBool(value, defaultValue, SearchExcludeUnreviewedKey)
|
||||
}
|
||||
|
@ -289,6 +295,19 @@ func parseBool(value string, defaultValue bool, key string) (bool, gtserror.With
|
|||
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) {
|
||||
if value == "" {
|
||||
return defaultValue, nil
|
||||
|
|
|
@ -20,6 +20,7 @@ package bundb
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
|
@ -27,6 +28,7 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"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) {
|
||||
reportIDs := []string{}
|
||||
func (r *reportDB) GetReports(ctx context.Context, resolved *bool, accountID string, targetAccountID string, page *paging.Page) ([]*gtsmodel.Report, error) {
|
||||
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.
|
||||
NewSelect().
|
||||
TableExpr("? AS ?", bun.Ident("reports"), bun.Ident("report")).
|
||||
Column("report.id").
|
||||
Order("report.id DESC")
|
||||
// Select only IDs from table.
|
||||
Column("report.id")
|
||||
|
||||
if resolved != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
// Return only reports with id
|
||||
// lower than provided maxID.
|
||||
if maxID != "" {
|
||||
q = q.Where("? < ?", bun.Ident("report.id"), maxID)
|
||||
}
|
||||
|
||||
if sinceID != "" {
|
||||
q = q.Where("? > ?", bun.Ident("report.id"), minID)
|
||||
}
|
||||
|
||||
// Return only reports with id
|
||||
// greater than provided minID.
|
||||
if minID != "" {
|
||||
q = q.Where("? > ?", bun.Ident("report.id"), minID)
|
||||
}
|
||||
|
||||
if limit != 0 {
|
||||
if limit > 0 {
|
||||
// Limit amount of
|
||||
// reports returned.
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -102,6 +123,12 @@ func (r *reportDB) GetReports(ctx context.Context, resolved *bool, accountID str
|
|||
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)
|
||||
reports := make([]*gtsmodel.Report, 0, len(reportIDs))
|
||||
for _, id := range reportIDs {
|
||||
|
|
|
@ -24,6 +24,8 @@ import (
|
|||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"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/testrig"
|
||||
)
|
||||
|
@ -61,14 +63,109 @@ func (suite *ReportTestSuite) TestGetReportByURI() {
|
|||
}
|
||||
|
||||
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.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() {
|
||||
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.NotEmpty(reports)
|
||||
for _, r := range reports {
|
||||
|
|
|
@ -21,6 +21,7 @@ import (
|
|||
"context"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||
)
|
||||
|
||||
// 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.
|
||||
// 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(ctx context.Context, report *gtsmodel.Report) error
|
||||
|
|
|
@ -21,73 +21,81 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/ap"
|
||||
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/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"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(
|
||||
ctx context.Context,
|
||||
account *gtsmodel.Account,
|
||||
resolved *bool,
|
||||
accountID string,
|
||||
targetAccountID string,
|
||||
maxID string,
|
||||
sinceID string,
|
||||
minID string,
|
||||
limit int,
|
||||
page *paging.Page,
|
||||
) (*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) {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
count := len(reports)
|
||||
if count == 0 {
|
||||
return util.EmptyPageableResponse(), nil
|
||||
return paging.EmptyResponse(), nil
|
||||
}
|
||||
|
||||
var (
|
||||
items = make([]interface{}, 0, count)
|
||||
nextMaxIDValue = reports[count-1].ID
|
||||
prevMinIDValue = reports[0].ID
|
||||
)
|
||||
// Get the lowest and highest
|
||||
// ID values, used for paging.
|
||||
lo := reports[count-1].ID
|
||||
hi := reports[0].ID
|
||||
|
||||
// Convert each report to API model.
|
||||
items := make([]interface{}, 0, count)
|
||||
for _, r := range reports {
|
||||
item, err := p.converter.ReportToAdminAPIReport(ctx, r, account)
|
||||
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)
|
||||
}
|
||||
|
||||
extraQueryParams := make([]string, 0, 3)
|
||||
// Assemble next/prev page queries.
|
||||
query := make(url.Values, 3)
|
||||
if resolved != nil {
|
||||
extraQueryParams = append(extraQueryParams, "resolved="+strconv.FormatBool(*resolved))
|
||||
query.Set(apiutil.ResolvedKey, strconv.FormatBool(*resolved))
|
||||
}
|
||||
if accountID != "" {
|
||||
extraQueryParams = append(extraQueryParams, "account_id="+accountID)
|
||||
query.Set(apiutil.AccountIDKey, accountID)
|
||||
}
|
||||
if targetAccountID != "" {
|
||||
extraQueryParams = append(extraQueryParams, "target_account_id="+targetAccountID)
|
||||
query.Set(apiutil.TargetAccountIDKey, targetAccountID)
|
||||
}
|
||||
|
||||
return util.PackagePageableResponse(util.PageableResponseParams{
|
||||
Items: items,
|
||||
Path: "/api/v1/admin/reports",
|
||||
NextMaxIDValue: nextMaxIDValue,
|
||||
PrevMinIDValue: prevMinIDValue,
|
||||
Limit: limit,
|
||||
ExtraQueryParams: extraQueryParams,
|
||||
})
|
||||
return paging.PackageResponse(paging.ResponseParams{
|
||||
Items: items,
|
||||
Path: "/api/v1/admin/reports",
|
||||
Next: page.Next(lo, hi),
|
||||
Prev: page.Prev(lo, hi),
|
||||
Query: query,
|
||||
}), nil
|
||||
}
|
||||
|
||||
// ReportGet returns one report, with the given ID.
|
||||
|
|
|
@ -21,13 +21,15 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
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/gtserror"
|
||||
"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.
|
||||
|
@ -53,53 +55,61 @@ func (p *Processor) Get(ctx context.Context, account *gtsmodel.Account, id strin
|
|||
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(
|
||||
ctx context.Context,
|
||||
account *gtsmodel.Account,
|
||||
resolved *bool,
|
||||
targetAccountID string,
|
||||
maxID string,
|
||||
sinceID string,
|
||||
minID string,
|
||||
limit int,
|
||||
page *paging.Page,
|
||||
) (*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) {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
count := len(reports)
|
||||
if count == 0 {
|
||||
return util.EmptyPageableResponse(), nil
|
||||
return paging.EmptyResponse(), nil
|
||||
}
|
||||
|
||||
items := make([]interface{}, 0, count)
|
||||
nextMaxIDValue := reports[count-1].ID
|
||||
prevMinIDValue := reports[0].ID
|
||||
// Get the lowest and highest
|
||||
// ID values, used for paging.
|
||||
lo := reports[count-1].ID
|
||||
hi := reports[0].ID
|
||||
|
||||
// Convert each report to API model.
|
||||
items := make([]interface{}, 0, count)
|
||||
for _, r := range reports {
|
||||
item, err := p.converter.ReportToAPIReport(ctx, r)
|
||||
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)
|
||||
}
|
||||
|
||||
extraQueryParams := []string{}
|
||||
// Assemble next/prev page queries.
|
||||
query := make(url.Values, 3)
|
||||
if resolved != nil {
|
||||
extraQueryParams = append(extraQueryParams, "resolved="+strconv.FormatBool(*resolved))
|
||||
query.Set(apiutil.ResolvedKey, strconv.FormatBool(*resolved))
|
||||
}
|
||||
if targetAccountID != "" {
|
||||
extraQueryParams = append(extraQueryParams, "target_account_id="+targetAccountID)
|
||||
query.Set(apiutil.TargetAccountIDKey, targetAccountID)
|
||||
}
|
||||
|
||||
return util.PackagePageableResponse(util.PageableResponseParams{
|
||||
Items: items,
|
||||
Path: "/api/v1/reports",
|
||||
NextMaxIDValue: nextMaxIDValue,
|
||||
PrevMinIDValue: prevMinIDValue,
|
||||
Limit: limit,
|
||||
ExtraQueryParams: extraQueryParams,
|
||||
})
|
||||
return paging.PackageResponse(paging.ResponseParams{
|
||||
Items: items,
|
||||
Path: "/api/v1/reports",
|
||||
Next: page.Next(lo, hi),
|
||||
Prev: page.Prev(lo, hi),
|
||||
Query: query,
|
||||
}), nil
|
||||
}
|
||||
|
|
|
@ -33,6 +33,7 @@
|
|||
"react-redux": "^8.1.3",
|
||||
"redux": "^4.2.0",
|
||||
"redux-persist": "^6.0.0",
|
||||
"sanitize-html": "^2.13.0",
|
||||
"skulk": "^0.0.8-fix",
|
||||
"wouter": "^3.1.0"
|
||||
},
|
||||
|
@ -49,6 +50,7 @@
|
|||
"@types/parse-link-header": "^2.0.3",
|
||||
"@types/psl": "^1.1.1",
|
||||
"@types/react-dom": "^18.2.8",
|
||||
"@types/sanitize-html": "^2.11.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.4",
|
||||
"@typescript-eslint/parser": "^6.7.4",
|
||||
"autoprefixer": "^10.4.19",
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
242
web/source/settings/components/status.tsx
Normal file
242
web/source/settings/components/status.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -60,7 +60,7 @@ export default function Username({ account, linkTo, backLocation, classNames }:
|
|||
);
|
||||
|
||||
if (linkTo) {
|
||||
className += " spanlink";
|
||||
className += " pseudolink";
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
|
|
|
@ -21,29 +21,51 @@ import { gtsApi } from "../../gts-api";
|
|||
|
||||
import type {
|
||||
AdminReport,
|
||||
AdminReportListParams,
|
||||
AdminSearchReportParams,
|
||||
AdminReportResolveParams,
|
||||
AdminSearchReportResp,
|
||||
} from "../../../types/report";
|
||||
import parse from "parse-link-header";
|
||||
|
||||
const extended = gtsApi.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
listReports: build.query<AdminReport[], AdminReportListParams | void>({
|
||||
query: (params) => ({
|
||||
url: "/api/v1/admin/reports",
|
||||
params: {
|
||||
// Override provided limit.
|
||||
limit: 100,
|
||||
...params
|
||||
searchReports: build.query<AdminSearchReportResp, AdminSearchReportParams>({
|
||||
query: (form) => {
|
||||
const params = new(URLSearchParams);
|
||||
Object.entries(form).forEach(([k, v]) => {
|
||||
if (v !== undefined) {
|
||||
params.append(k, v);
|
||||
}
|
||||
});
|
||||
|
||||
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>({
|
||||
query: (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>({
|
||||
|
@ -55,8 +77,8 @@ const extended = gtsApi.injectEndpoints({
|
|||
}),
|
||||
invalidatesTags: (res) =>
|
||||
res
|
||||
? [{ type: "Reports", id: "LIST" }, { type: "Reports", id: res.id }]
|
||||
: [{ type: "Reports", id: "LIST" }]
|
||||
? [{ type: "Report", id: "LIST" }, { type: "Report", id: res.id }]
|
||||
: [{ type: "Report", id: "LIST" }]
|
||||
})
|
||||
})
|
||||
});
|
||||
|
@ -64,7 +86,7 @@ const extended = gtsApi.injectEndpoints({
|
|||
/**
|
||||
* 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.
|
||||
|
@ -77,7 +99,7 @@ const useGetReportQuery = extended.useGetReportQuery;
|
|||
const useResolveReportMutation = extended.useResolveReportMutation;
|
||||
|
||||
export {
|
||||
useListReportsQuery,
|
||||
useLazySearchReportsQuery,
|
||||
useGetReportQuery,
|
||||
useResolveReportMutation,
|
||||
};
|
||||
|
|
|
@ -136,7 +136,7 @@ export const gtsApi = createApi({
|
|||
tagTypes: [
|
||||
"Auth",
|
||||
"Emoji",
|
||||
"Reports",
|
||||
"Report",
|
||||
"Account",
|
||||
"InstanceRules",
|
||||
"HTTPHeaderAllows",
|
||||
|
|
|
@ -17,6 +17,10 @@
|
|||
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
|
||||
* model, which contains less detailed information.
|
||||
|
@ -56,29 +60,25 @@ export interface AdminReport {
|
|||
updated_at: string;
|
||||
/**
|
||||
* Account that created the report.
|
||||
* TODO: model this properly.
|
||||
*/
|
||||
account: Object;
|
||||
account: AdminAccount;
|
||||
/**
|
||||
* Reported account.
|
||||
* TODO: model this properly.
|
||||
*/
|
||||
target_account: Object;
|
||||
target_account: AdminAccount;
|
||||
/**
|
||||
* 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.
|
||||
* TODO: model this properly.
|
||||
*/
|
||||
action_taken_by_account?: Object;
|
||||
action_taken_by_account?: AdminAccount;
|
||||
/**
|
||||
* Statuses cited by this report, if any.
|
||||
* TODO: model this properly.
|
||||
*/
|
||||
statuses: Object[];
|
||||
statuses: Status[];
|
||||
/**
|
||||
* Rules broken according to the reporter, if any.
|
||||
* TODO: model this properly.
|
||||
|
@ -108,7 +108,7 @@ export interface AdminReportResolveParams {
|
|||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
@ -142,3 +142,8 @@ export interface AdminReportListParams {
|
|||
*/
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface AdminSearchReportResp {
|
||||
accounts: AdminReport[];
|
||||
links: Links | null;
|
||||
}
|
||||
|
|
83
web/source/settings/lib/types/status.ts
Normal file
83
web/source/settings/lib/types/status.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -19,8 +19,8 @@
|
|||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { AdminAccount } from "../../../../lib/types/account";
|
||||
import { store } from "../../../../redux/store";
|
||||
import { AdminAccount } from "../types/account";
|
||||
import { store } from "../../redux/store";
|
||||
|
||||
export function yesOrNo(b: boolean): string {
|
||||
return b ? "yes" : "no";
|
|
@ -1045,62 +1045,62 @@ button.with-padding {
|
|||
}
|
||||
}
|
||||
|
||||
.reports {
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.reports-view {
|
||||
.report {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.5rem;
|
||||
margin: 0.5rem 0;
|
||||
|
||||
text-decoration: none;
|
||||
color: $fg;
|
||||
|
||||
padding: 1rem;
|
||||
|
||||
border: none;
|
||||
border-left: 0.3rem solid $border-accent;
|
||||
|
||||
.usernames {
|
||||
line-height: 2rem;
|
||||
}
|
||||
.username-lozenge {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
|
||||
.byline {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.5rem;
|
||||
|
||||
.report-status {
|
||||
color: $border-accent;
|
||||
.fa {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.details {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.2rem 0.5rem;
|
||||
padding: 0.5rem;
|
||||
|
||||
justify-items: start;
|
||||
.report-byline {
|
||||
max-width: fit-content;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
.info-list {
|
||||
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 {
|
||||
color: $fg-reduced;
|
||||
border-left: 0.4rem solid $bg;
|
||||
border-left: 0.3rem solid $list-entry-bg;
|
||||
|
||||
.byline .report-status {
|
||||
.info-list,
|
||||
.info-list .info-list-entry .reported-by .username-lozenge {
|
||||
color: $fg-reduced;
|
||||
}
|
||||
|
||||
.user {
|
||||
opacity: 0.8;
|
||||
|
||||
&:hover {
|
||||
border-color: $fg-accent;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1109,70 +1109,40 @@ button.with-padding {
|
|||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.report.detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 1rem;
|
||||
gap: 1rem;
|
||||
|
||||
.info-block {
|
||||
padding: 0.5rem;
|
||||
background: $gray2;
|
||||
.report-detail {
|
||||
.info-list {
|
||||
|
||||
&.overview {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.info {
|
||||
display: block;
|
||||
}
|
||||
.username-lozenge {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
max-width: fit-content;
|
||||
|
||||
.reported-toots {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
.fa {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
.report-statuses {
|
||||
width: min(100%, 50rem);
|
||||
|
||||
.thread {
|
||||
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 {
|
||||
|
@ -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 {
|
||||
border: 0.1rem solid $gray1;
|
||||
display: flex;
|
||||
|
|
|
@ -22,7 +22,7 @@ import { Redirect, useParams } from "wouter";
|
|||
import { useComboBoxInput, useFileInput, useValue } from "../../../../lib/form";
|
||||
import useFormSubmit from "../../../../lib/form/submit";
|
||||
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 Loading from "../../../../components/loading";
|
||||
import { FileInput } from "../../../../components/form/inputs";
|
||||
|
@ -124,14 +124,14 @@ function EmojiDetailForm({ data: emoji }) {
|
|||
disabled={!form.image.value}
|
||||
/>
|
||||
|
||||
<FakeToot>
|
||||
<FakeStatus>
|
||||
Look at this new custom emoji <img
|
||||
className="emoji"
|
||||
src={form.image.previewValue ?? emoji.url}
|
||||
title={`:${emoji.shortcode}:`}
|
||||
alt={emoji.shortcode}
|
||||
/> isn't it cool?
|
||||
</FakeToot>
|
||||
</FakeStatus>
|
||||
|
||||
{result.error && <Error error={result.error} />}
|
||||
{deleteResult.error && <Error error={deleteResult.error} />}
|
||||
|
|
|
@ -23,7 +23,7 @@ import useShortcode from "./use-shortcode";
|
|||
import useFormSubmit from "../../../../lib/form/submit";
|
||||
import { TextInput, FileInput } from "../../../../components/form/inputs";
|
||||
import { CategorySelect } from '../category-select';
|
||||
import FakeToot from "../../../../components/fake-toot";
|
||||
import { FakeStatus } from "../../../../components/status";
|
||||
import MutationButton from "../../../../components/form/mutation-button";
|
||||
import { useAddEmojiMutation } from "../../../../lib/query/admin/custom-emoji";
|
||||
import { useInstanceV1Query } from "../../../../lib/query/gts-api";
|
||||
|
@ -103,9 +103,9 @@ export default function NewEmojiForm() {
|
|||
<div>
|
||||
<h2>Add new custom emoji</h2>
|
||||
|
||||
<FakeToot>
|
||||
<FakeStatus>
|
||||
Look at this new custom emoji {emojiOrShortcode} isn't it cool?
|
||||
</FakeToot>
|
||||
</FakeStatus>
|
||||
|
||||
<form onSubmit={submitForm} className="form-flex">
|
||||
<FileInput
|
||||
|
|
|
@ -69,7 +69,7 @@ export default function HeaderPermsOverview() {
|
|||
return (
|
||||
<dl
|
||||
key={perm.id}
|
||||
className="entry spanlink"
|
||||
className="entry pseudolink"
|
||||
onClick={() => {
|
||||
// When clicking on a header perm,
|
||||
// go to the detail view for perm.
|
||||
|
|
|
@ -21,13 +21,13 @@ import React from "react";
|
|||
|
||||
import { useGetAccountQuery } from "../../../../lib/query/admin";
|
||||
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 { AccountActions } from "./actions";
|
||||
import { useParams } from "wouter";
|
||||
import { useBaseUrl } from "../../../../lib/navigation/util";
|
||||
import BackButton from "../../../../components/back-button";
|
||||
import { UseOurInstanceAccount, yesOrNo } from "./util";
|
||||
import { UseOurInstanceAccount, yesOrNo } from "../../../../lib/util";
|
||||
|
||||
export default function AccountDetail() {
|
||||
const params: { accountID: string } = useParams();
|
||||
|
|
|
@ -83,7 +83,7 @@ export function AccountSearchForm() {
|
|||
}
|
||||
|
||||
// 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 itemToEntry(account: AdminAccount): ReactNode {
|
||||
|
|
|
@ -17,8 +17,8 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useParams } from "wouter";
|
||||
import React from "react";
|
||||
import { useLocation, useParams } from "wouter";
|
||||
import FormWithData from "../../../lib/form/form-with-data";
|
||||
import BackButton from "../../../components/back-button";
|
||||
import { useValue, useTextInput } from "../../../lib/form";
|
||||
|
@ -28,84 +28,172 @@ import MutationButton from "../../../components/form/mutation-button";
|
|||
import Username from "../../../components/username";
|
||||
import { useGetReportQuery, useResolveReportMutation } from "../../../lib/query/admin/reports";
|
||||
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({ }) {
|
||||
const params: { reportId: string } = useParams();
|
||||
const baseUrl = useBaseUrl();
|
||||
const params = useParams();
|
||||
const backLocation: String = history.state?.backLocation ?? `~${baseUrl}`;
|
||||
|
||||
return (
|
||||
<div className="reports">
|
||||
<h1><BackButton to={`~${baseUrl}`}/> Report Details</h1>
|
||||
<div className="report-detail">
|
||||
<h1><BackButton to={backLocation}/> Report Details</h1>
|
||||
<FormWithData
|
||||
dataQuery={useGetReportQuery}
|
||||
queryArg={params.reportId}
|
||||
DataForm={ReportDetailForm}
|
||||
{...{ backLocation: backLocation }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportDetailForm({ data: report }) {
|
||||
function ReportDetailForm({ data: report }: { data: AdminReport }) {
|
||||
const [ location ] = useLocation();
|
||||
const baseUrl = useBaseUrl();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReportBasicInfo
|
||||
report={report}
|
||||
baseUrl={baseUrl}
|
||||
location={location}
|
||||
/>
|
||||
|
||||
{ report.action_taken
|
||||
&& <ReportHistory
|
||||
report={report}
|
||||
baseUrl={baseUrl}
|
||||
location={location}
|
||||
/>
|
||||
}
|
||||
|
||||
{ report.statuses &&
|
||||
<ReportStatuses report={report} />
|
||||
}
|
||||
|
||||
{ !report.action_taken &&
|
||||
<ReportActionForm report={report} />
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReportSectionProps {
|
||||
report: AdminReport;
|
||||
baseUrl: string;
|
||||
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 (
|
||||
<div className="report detail">
|
||||
<div className="usernames">
|
||||
<Username
|
||||
account={from}
|
||||
linkTo={`~/settings/moderation/accounts/${from.id}`}
|
||||
backLocation={`~/settings/moderation/reports/${report.id}`}
|
||||
/>
|
||||
<> reported </>
|
||||
<Username
|
||||
account={target}
|
||||
linkTo={`~/settings/moderation/accounts/${target.id}`}
|
||||
backLocation={`~/settings/moderation/reports/${report.id}`}
|
||||
/>
|
||||
<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>
|
||||
|
||||
{report.action_taken &&
|
||||
<div className="info">
|
||||
<h3>Resolved by @{report.action_taken_by_account.account.acct}</h3>
|
||||
<span className="timestamp">at {new Date(report.action_taken_at).toLocaleString()}</span>
|
||||
<br />
|
||||
<b>Comment: </b><span>{report.action_taken_comment}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="info-block">
|
||||
<h3>Report info:</h3>
|
||||
<div className="details">
|
||||
<b>Created: </b>
|
||||
<span>{new Date(report.created_at).toLocaleString()}</span>
|
||||
|
||||
<b>Forwarded: </b> <span>{report.forwarded ? "Yes" : "No"}</span>
|
||||
<b>Category: </b> <span>{report.category}</span>
|
||||
|
||||
<b>Reason: </b>
|
||||
{report.comment.length > 0
|
||||
? <p>{report.comment}</p>
|
||||
: <i className="no-comment">none provided</i>
|
||||
<div className="info-list-entry">
|
||||
<dt>Status</dt>
|
||||
<dd>
|
||||
{ report.action_taken
|
||||
? <>{status}</>
|
||||
: <b>{status}</b>
|
||||
}
|
||||
|
||||
</div>
|
||||
</dd>
|
||||
</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>
|
||||
|
||||
{
|
||||
report.statuses.length > 0 &&
|
||||
<div className="info-block">
|
||||
<h3>Reported toots ({report.statuses.length}):</h3>
|
||||
<div className="reported-toots">
|
||||
{report.statuses.map((status) => (
|
||||
<ReportedToot key={status.id} toot={status} />
|
||||
))}
|
||||
</div>
|
||||
<div className="info-list-entry">
|
||||
<dt>Created</dt>
|
||||
<dd>
|
||||
<time dateTime={report.created_at}>{created}</time>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div className="info-list-entry">
|
||||
<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 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 });
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="info-block">
|
||||
<h3>Resolving this report</h3>
|
||||
<p>
|
||||
<form onSubmit={submit}>
|
||||
<h3>Resolve this report</h3>
|
||||
<>
|
||||
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 />
|
||||
<b>This will be visible to the user that created the report!</b>
|
||||
</p>
|
||||
This is useful for providing an explanation about what action was
|
||||
taken (if any) before the report was marked as resolved.
|
||||
<br />
|
||||
<b>
|
||||
Any comment made here will be visible
|
||||
to the user that created the report!
|
||||
</b>
|
||||
</>
|
||||
<TextArea
|
||||
field={form.comment}
|
||||
label="Comment"
|
||||
|
@ -138,116 +231,24 @@ function ReportActionForm({ report }) {
|
|||
);
|
||||
}
|
||||
|
||||
function ReportedToot({ toot }) {
|
||||
const account = toot.account;
|
||||
|
||||
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);
|
||||
function ReportStatuses({ report }: { report: AdminReport }) {
|
||||
if (report.statuses.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="spoiler">
|
||||
<span>{note}</span>
|
||||
<label className="button spoiler-label" onClick={toggleVisible}>Show {visible ? "less" : "more"}</label>
|
||||
</div>
|
||||
{visible && content}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TootMedia({ media, sensitive }) {
|
||||
let classes = (media.length % 2 == 0) ? "even" : "odd";
|
||||
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 className="report-statuses">
|
||||
<h3>Reported Statuses</h3>
|
||||
<ul className="thread">
|
||||
{ report.statuses.map((status) => {
|
||||
return (
|
||||
<Status
|
||||
key={status.id}
|
||||
status={status}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
252
web/source/settings/views/moderation/reports/search.tsx
Normal file
252
web/source/settings/views/moderation/reports/search.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -20,7 +20,7 @@
|
|||
import React from "react";
|
||||
import { BaseUrlContext, useBaseUrl, useHasPermission } from "../../lib/navigation/util";
|
||||
import { Redirect, Route, Router, Switch } from "wouter";
|
||||
import { ReportOverview } from "./reports/overview";
|
||||
import ReportsSearch from "./reports/search";
|
||||
import ReportDetail from "./reports/detail";
|
||||
import { ErrorBoundary } from "../../lib/navigation/error";
|
||||
import ImportExport from "./domain-permissions/import-export";
|
||||
|
@ -85,8 +85,9 @@ function ModerationReportsRouter() {
|
|||
<Router base={thisBase}>
|
||||
<ErrorBoundary>
|
||||
<Switch>
|
||||
<Route path="/search" component={ReportsSearch}/>
|
||||
<Route path={"/:reportId"} component={ReportDetail} />
|
||||
<Route component={ReportOverview}/>
|
||||
<Route><Redirect to="/search"/></Route>
|
||||
</Switch>
|
||||
</ErrorBoundary>
|
||||
</Router>
|
||||
|
|
|
@ -39,7 +39,7 @@ import {
|
|||
} from "../../components/form/inputs";
|
||||
|
||||
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 { useAccountThemesQuery } from "../../lib/query/user";
|
||||
|
|
|
@ -1499,6 +1499,13 @@
|
|||
"@types/scheduler" "*"
|
||||
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@*":
|
||||
version "0.16.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.4.tgz#fedc3e5b15c26dc18faae96bf1317487cb3658cf"
|
||||
|
@ -3125,11 +3132,41 @@ doctrine@^3.0.0:
|
|||
dependencies:
|
||||
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:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda"
|
||||
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:
|
||||
version "1.1.1"
|
||||
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"
|
||||
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:
|
||||
version "1.3.2"
|
||||
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"
|
||||
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:
|
||||
version "2.0.0"
|
||||
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"
|
||||
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:
|
||||
version "4.0.2"
|
||||
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"
|
||||
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:
|
||||
version "1.3.3"
|
||||
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"
|
||||
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:
|
||||
version "8.4.31"
|
||||
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"
|
||||
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:
|
||||
version "0.23.0"
|
||||
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"
|
||||
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:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/source-map-loader/-/source-map-loader-4.0.1.tgz#72f00d05f5d1f90f80974eda781cbd7107c125f2"
|
||||
|
|
Loading…
Reference in a new issue