[feature] Implement reports admin API so admins can view + close reports (#1378)

* add admin report api endpoints + tests

* [chore] remove funky duplicate attachment in testrig
This commit is contained in:
tobi 2023-01-25 11:12:17 +01:00 committed by GitHub
parent 27d4e364e0
commit faeb7ded3b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 2674 additions and 72 deletions

View File

@ -356,6 +356,103 @@ definitions:
type: object
x-go-name: Relationship
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
adminAccountInfo:
properties:
account:
$ref: '#/definitions/account'
approved:
description: Whether the account is currently approved.
type: boolean
x-go-name: Approved
confirmed:
description: Whether the account has confirmed their email address.
type: boolean
x-go-name: Confirmed
created_at:
description: When the account was first discovered. (ISO 8601 Datetime)
example: "2021-07-30T09:20:25+00:00"
type: string
x-go-name: CreatedAt
created_by_application_id:
description: The ID of the application that created this account.
type: string
x-go-name: CreatedByApplicationID
disabled:
description: Whether the account is currently disabled.
type: boolean
x-go-name: Disabled
domain:
description: |-
The domain of the account.
Null for local accounts.
example: example.org
type: string
x-go-name: Domain
email:
description: |-
The email address associated with the account.
Empty string for remote accounts or accounts with
no known email address.
example: someone@somewhere.com
type: string
x-go-name: Email
id:
description: The ID of the account in the database.
example: 01GQ4PHNT622DQ9X95XQX4KKNR
type: string
x-go-name: ID
invite_request:
description: |-
The reason given when requesting an invite.
Null if not known / remote account.
example: Pleaaaaaaaaaaaaaaase!!
type: string
x-go-name: InviteRequest
invited_by_account_id:
description: The ID of the account that invited this user
type: string
x-go-name: InvitedByAccountID
ip:
description: |-
The IP address last used to login to this account.
Null if not known.
example: 192.0.2.1
type: string
x-go-name: IP
ips:
description: |-
All known IP addresses associated with this account.
NOT IMPLEMENTED (will always be empty array).
example: []
items: {}
type: array
x-go-name: IPs
locale:
description: The locale of the account. (ISO 639 Part 1 two-letter language code)
example: en
type: string
x-go-name: Locale
role:
description: The current role of the account.
type: string
x-go-name: Role
silenced:
description: Whether the account is currently silenced
type: boolean
x-go-name: Silenced
suspended:
description: Whether the account is currently suspended.
type: boolean
x-go-name: Suspended
username:
description: The username of the account.
example: dril
type: string
x-go-name: Username
title: AdminAccountInfo models the admin view of an account's details.
type: object
x-go-name: AdminAccountInfo
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
adminEmoji:
properties:
category:
@ -423,6 +520,86 @@ definitions:
type: object
x-go-name: AdminEmoji
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
adminReport:
properties:
account:
$ref: '#/definitions/adminAccountInfo'
action_taken:
description: Whether an action has been taken by an admin in response to this report.
example: false
type: boolean
x-go-name: ActionTaken
action_taken_at:
description: |-
If an action was taken, at what time was this done? (ISO 8601 Datetime)
Will be null if not set / no action yet taken.
example: "2021-07-30T09:20:25+00:00"
type: string
x-go-name: ActionTakenAt
action_taken_by_account:
$ref: '#/definitions/adminAccountInfo'
action_taken_comment:
description: |-
If an action was taken, what comment was made by the admin on the taken action?
Will be null if not set / no action yet taken.
example: Account was suspended.
type: string
x-go-name: ActionTakenComment
assigned_account:
$ref: '#/definitions/adminAccountInfo'
category:
description: Under what category was this report created?
example: spam
type: string
x-go-name: Category
comment:
description: |-
Comment submitted when the report was created.
Will be empty if no comment was submitted.
example: This person has been harassing me.
type: string
x-go-name: Comment
created_at:
description: The date when this report was created (ISO 8601 Datetime).
example: "2021-07-30T09:20:25+00:00"
type: string
x-go-name: CreatedAt
forwarded:
description: Bool to indicate that report should be federated to remote instance.
example: true
type: boolean
x-go-name: Forwarded
id:
description: ID of the report.
example: 01FBVD42CQ3ZEEVMW180SBX03B
type: string
x-go-name: ID
rule_ids:
description: |-
Array of rule IDs that were submitted along with this report.
NOT IMPLEMENTED, will always be empty array.
items: {}
type: array
x-go-name: Rules
statuses:
description: |-
Array of statuses that were submitted along with this report.
Will be empty if no status IDs were submitted with the report.
items:
$ref: '#/definitions/status'
type: array
x-go-name: Statuses
target_account:
$ref: '#/definitions/adminAccountInfo'
updated_at:
description: Time of last action on this report (ISO 8601 Datetime).
example: "2021-07-30T09:20:25+00:00"
type: string
x-go-name: UpdatedAt
title: AdminReport models the admin view of a report.
type: object
x-go-name: AdminReport
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
advancedVisibilityFlagsForm:
description: |-
AdvancedVisibilityFlagsForm allows a few more advanced flags to be set on new statuses, in addition
@ -1278,71 +1455,12 @@ definitions:
mediaMeta:
description: This can be metadata about an image, an audio file, video, etc.
properties:
aspect:
description: |-
Aspect ratio of the media.
Equal to width / height.
example: 1.777777778
format: float
type: number
x-go-name: Aspect
audio_bitrate:
type: string
x-go-name: AudioBitrate
audio_channels:
type: string
x-go-name: AudioChannels
audio_encode:
type: string
x-go-name: AudioEncode
duration:
description: |-
Duration of the media in seconds.
Only set for video and audio.
example: 5.43
format: float
type: number
x-go-name: Duration
focus:
$ref: '#/definitions/mediaFocus'
fps:
description: |-
Framerate of the media.
Only set for video and gifs.
example: 30
format: uint16
type: integer
x-go-name: FPS
height:
description: |-
Height of the media in pixels.
Not set for audio.
example: 1080
format: int64
type: integer
x-go-name: Height
length:
type: string
x-go-name: Length
original:
$ref: '#/definitions/mediaDimensions'
size:
description: |-
Size of the media, in the format `[width]x[height]`.
Not set for audio.
example: 1920x1080
type: string
x-go-name: Size
small:
$ref: '#/definitions/mediaDimensions'
width:
description: |-
Width of the media in pixels.
Not set for audio.
example: 1920
format: int64
type: integer
x-go-name: Width
title: MediaMeta models media metadata.
type: object
x-go-name: MediaMeta
@ -1530,7 +1648,7 @@ definitions:
Will be null if not set / no action yet taken.
example: Account was suspended.
type: string
x-go-name: ActionComment
x-go-name: ActionTakenComment
category:
description: Under what category was this report created?
example: spam
@ -3333,6 +3451,147 @@ paths:
summary: Refetch media specified in the database but missing from storage.
tags:
- admin
/api/v1/admin/reports:
get:
description: |-
The reports will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer).
The next and previous queries can be parsed from the returned Link header.
Example:
```
<https://example.org/api/v1/admin/reports?limit=20&max_id=01FC0SKA48HNSVR6YKZCQGS2V8>; rel="next", <https://example.org/api/v1/admin/reports?limit=20&min_id=01FC0SKW5JK2Q4EVAV2B462YY0>; rel="prev"
````
operationId: adminReports
parameters:
- description: If set to true, only resolved reports will be returned. If false, only unresolved reports will be returned. If unset, reports will not be filtered on their resolved status.
in: query
name: resolved
type: boolean
- description: Return only reports created by the given account id.
in: query
name: account_id
type: string
- description: Return only reports that target the given account id.
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.
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.
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.
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.
in: query
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: Array of reports.
schema:
items:
$ref: '#/definitions/adminReport'
type: array
"400":
description: bad request
"401":
description: unauthorized
"404":
description: not found
"406":
description: not acceptable
"500":
description: internal server error
security:
- OAuth2 Bearer:
- admin
summary: View user moderation reports.
tags:
- admin
/api/v1/admin/reports/{id}:
get:
operationId: adminReportGet
parameters:
- description: The id of the report.
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: The requested report.
schema:
$ref: '#/definitions/adminReport'
"400":
description: bad request
"401":
description: unauthorized
"404":
description: not found
"406":
description: not acceptable
"500":
description: internal server error
security:
- OAuth2 Bearer:
- admin
summary: View user moderation report with the given id.
tags:
- admin
/api/v1/admin/reports/{id}/resolve:
post:
consumes:
- application/json
- application/xml
- multipart/form-data
operationId: adminReportResolve
parameters:
- description: The id of the report.
in: path
name: id
required: true
type: string
- description: Optional admin comment on the action taken in response to this report. Useful for providing an explanation about what action was taken (if any) before the report was marked as resolved. This will be visible to the user that created the report!
example: The reported account was suspended.
in: formData
name: action_taken_comment
type: string
produces:
- application/json
responses:
"200":
description: The resolved report.
schema:
$ref: '#/definitions/adminReport'
"400":
description: bad request
"401":
description: unauthorized
"404":
description: not found
"406":
description: not acceptable
"500":
description: internal server error
security:
- OAuth2 Bearer:
- admin
summary: Mark a report as resolved.
tags:
- admin
/api/v1/apps:
post:
consumes:

View File

@ -46,6 +46,12 @@ const (
AccountsActionPath = AccountsPathWithID + "/action"
MediaCleanupPath = BasePath + "/media_cleanup"
MediaRefetchPath = BasePath + "/media_refetch"
// ReportsPath is for serving admin view of user reports.
ReportsPath = BasePath + "/reports"
// ReportsPathWithID is for viewing/acting on one report.
ReportsPathWithID = ReportsPath + "/:" + IDKey
// ReportsResolvePath is for marking one report as resolved.
ReportsResolvePath = ReportsPathWithID + "/resolve"
// ExportQueryKey is for requesting a public export of some data.
ExportQueryKey = "export"
@ -65,6 +71,15 @@ const (
LimitKey = "limit"
// DomainQueryKey is for specifying a domain during admin actions.
DomainQueryKey = "domain"
// ResolvedKey is for filtering reports by their resolved status
ResolvedKey = "resolved"
// AccountIDKey is for selecting account in API paths.
AccountIDKey = "account_id"
// TargetAccountIDKey is for selecting target account in API paths.
TargetAccountIDKey = "target_account_id"
MaxIDKey = "max_id"
SinceIDKey = "since_id"
MinIDKey = "min_id"
)
type Module struct {
@ -78,17 +93,29 @@ func New(processor processing.Processor) *Module {
}
func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) {
// emoji stuff
attachHandler(http.MethodPost, EmojiPath, m.EmojiCreatePOSTHandler)
attachHandler(http.MethodGet, EmojiPath, m.EmojisGETHandler)
attachHandler(http.MethodDelete, EmojiPathWithID, m.EmojiDELETEHandler)
attachHandler(http.MethodGet, EmojiPathWithID, m.EmojiGETHandler)
attachHandler(http.MethodPatch, EmojiPathWithID, m.EmojiPATCHHandler)
attachHandler(http.MethodGet, EmojiCategoriesPath, m.EmojiCategoriesGETHandler)
// domain block stuff
attachHandler(http.MethodPost, DomainBlocksPath, m.DomainBlocksPOSTHandler)
attachHandler(http.MethodGet, DomainBlocksPath, m.DomainBlocksGETHandler)
attachHandler(http.MethodGet, DomainBlocksPathWithID, m.DomainBlockGETHandler)
attachHandler(http.MethodDelete, DomainBlocksPathWithID, m.DomainBlockDELETEHandler)
// accounts stuff
attachHandler(http.MethodPost, AccountsActionPath, m.AccountActionPOSTHandler)
// media stuff
attachHandler(http.MethodPost, MediaCleanupPath, m.MediaCleanupPOSTHandler)
attachHandler(http.MethodPost, MediaRefetchPath, m.MediaRefetchPOSTHandler)
attachHandler(http.MethodGet, EmojiCategoriesPath, m.EmojiCategoriesGETHandler)
// reports stuff
attachHandler(http.MethodGet, ReportsPath, m.ReportsGETHandler)
attachHandler(http.MethodGet, ReportsPathWithID, m.ReportGETHandler)
attachHandler(http.MethodPost, ReportsResolvePath, m.ReportResolvePOSTHandler)
}

View File

@ -62,6 +62,7 @@ type AdminStandardTestSuite struct {
testStatuses map[string]*gtsmodel.Status
testEmojis map[string]*gtsmodel.Emoji
testEmojiCategories map[string]*gtsmodel.EmojiCategory
testReports map[string]*gtsmodel.Report
// module being tested
adminModule *admin.Module
@ -77,6 +78,7 @@ func (suite *AdminStandardTestSuite) SetupSuite() {
suite.testStatuses = testrig.NewTestStatuses()
suite.testEmojis = testrig.NewTestEmojis()
suite.testEmojiCategories = testrig.NewTestEmojiCategories()
suite.testReports = testrig.NewTestReports()
}
func (suite *AdminStandardTestSuite) SetupTest() {

View File

@ -0,0 +1,103 @@
/*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package admin
import (
"errors"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
// ReportGETHandler swagger:operation GET /api/v1/admin/reports/{id} adminReportGet
//
// View user moderation report with the given id.
//
// ---
// tags:
// - admin
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// type: string
// description: The id of the report.
// in: path
// required: true
//
// security:
// - OAuth2 Bearer:
// - admin
//
// responses:
// '200':
// name: report
// description: The requested report.
// schema:
// "$ref": "#/definitions/adminReport"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) ReportGETHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet)
return
}
if !*authed.User.Admin {
err := fmt.Errorf("user %s not an admin", authed.User.ID)
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet)
return
}
reportID := c.Param(IDKey)
if reportID == "" {
err := errors.New("no report id specified")
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet)
return
}
report, errWithCode := m.processor.AdminReportGet(c.Request.Context(), authed, reportID)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet)
return
}
c.JSON(http.StatusOK, report)
}

View File

@ -0,0 +1,125 @@
/*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package admin
import (
"errors"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
// ReportResolvePOSTHandler swagger:operation POST /api/v1/admin/reports/{id}/resolve adminReportResolve
//
// Mark a report as resolved.
//
// ---
// tags:
// - admin
//
// consumes:
// - application/json
// - application/xml
// - multipart/form-data
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// type: string
// description: The id of the report.
// in: path
// required: true
// -
// name: action_taken_comment
// in: formData
// description: >-
// Optional admin comment on the action taken in response to this report.
// Useful for providing an explanation about what action was taken (if any)
// before the report was marked as resolved. This will be visible to the user
// that created the report!
// type: string
// example: The reported account was suspended.
//
// security:
// - OAuth2 Bearer:
// - admin
//
// responses:
// '200':
// name: report
// description: The resolved report.
// schema:
// "$ref": "#/definitions/adminReport"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) ReportResolvePOSTHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet)
return
}
if !*authed.User.Admin {
err := fmt.Errorf("user %s not an admin", authed.User.ID)
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet)
return
}
reportID := c.Param(IDKey)
if reportID == "" {
err := errors.New("no report id specified")
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet)
return
}
form := &apimodel.AdminReportResolveRequest{}
if err := c.ShouldBind(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet)
return
}
report, errWithCode := m.processor.AdminReportResolve(c.Request.Context(), authed, reportID, form.ActionTakenComment)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet)
return
}
c.JSON(http.StatusOK, report)
}

View File

@ -0,0 +1,168 @@
/*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package admin_test
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/admin"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/testrig"
)
type ReportResolveTestSuite struct {
AdminStandardTestSuite
}
func (suite *ReportResolveTestSuite) resolveReport(
account *gtsmodel.Account,
token *gtsmodel.Token,
user *gtsmodel.User,
targetReportID string,
expectedHTTPStatus int,
expectedBody string,
actionTakenComment *string,
) (*apimodel.AdminReport, error) {
// instantiate recorder + test context
recorder := httptest.NewRecorder()
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
ctx.Set(oauth.SessionAuthorizedAccount, account)
ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(token))
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
ctx.Set(oauth.SessionAuthorizedUser, user)
// create the request URI
requestPath := admin.ReportsPath + "/" + targetReportID + "/resolve"
baseURI := config.GetProtocol() + "://" + config.GetHost()
requestURI := baseURI + "/api/" + requestPath
// create the request
ctx.Request = httptest.NewRequest(http.MethodPost, requestURI, nil)
ctx.AddParam(admin.IDKey, targetReportID)
ctx.Request.Header.Set("accept", "application/json")
if actionTakenComment != nil {
ctx.Request.Form = url.Values{"action_taken_comment": {*actionTakenComment}}
}
// trigger the handler
suite.adminModule.ReportResolvePOSTHandler(ctx)
// read the response
result := recorder.Result()
defer result.Body.Close()
b, err := ioutil.ReadAll(result.Body)
if err != nil {
return nil, err
}
errs := gtserror.MultiError{}
if resultCode := recorder.Code; expectedHTTPStatus != resultCode {
errs = append(errs, fmt.Sprintf("expected %d got %d", expectedHTTPStatus, resultCode))
}
// if we got an expected body, return early
if expectedBody != "" {
if string(b) != expectedBody {
errs = append(errs, fmt.Sprintf("expected %s got %s", expectedBody, string(b)))
}
return nil, errs.Combine()
}
resp := &apimodel.AdminReport{}
if err := json.Unmarshal(b, &resp); err != nil {
return nil, err
}
return resp, nil
}
func (suite *ReportResolveTestSuite) TestReportResolve1() {
testAccount := suite.testAccounts["admin_account"]
testToken := suite.testTokens["admin_account"]
testUser := suite.testUsers["admin_account"]
testReportID := suite.testReports["local_account_2_report_remote_account_1"].ID
var actionTakenComment *string = nil
report, err := suite.resolveReport(testAccount, testToken, testUser, testReportID, http.StatusOK, "", actionTakenComment)
suite.NoError(err)
suite.NotEmpty(report)
// report should be resolved
suite.True(report.ActionTaken)
actionTime, err := util.ParseISO8601(*report.ActionTakenAt)
if err != nil {
suite.FailNow(err.Error())
}
suite.WithinDuration(time.Now(), actionTime, 1*time.Minute)
updatedTime, err := util.ParseISO8601(report.UpdatedAt)
if err != nil {
suite.FailNow(err.Error())
}
suite.WithinDuration(time.Now(), updatedTime, 1*time.Minute)
suite.Equal(report.ActionTakenByAccount.ID, testAccount.ID)
suite.EqualValues(report.ActionTakenComment, actionTakenComment)
suite.EqualValues(report.AssignedAccount.ID, testAccount.ID)
}
func (suite *ReportResolveTestSuite) TestReportResolve2() {
testAccount := suite.testAccounts["admin_account"]
testToken := suite.testTokens["admin_account"]
testUser := suite.testUsers["admin_account"]
testReportID := suite.testReports["local_account_2_report_remote_account_1"].ID
var actionTakenComment *string = testrig.StringPtr("no action was taken, this is a frivolous report you boob")
report, err := suite.resolveReport(testAccount, testToken, testUser, testReportID, http.StatusOK, "", actionTakenComment)
suite.NoError(err)
suite.NotEmpty(report)
// report should be resolved
suite.True(report.ActionTaken)
actionTime, err := util.ParseISO8601(*report.ActionTakenAt)
if err != nil {
suite.FailNow(err.Error())
}
suite.WithinDuration(time.Now(), actionTime, 1*time.Minute)
updatedTime, err := util.ParseISO8601(report.UpdatedAt)
if err != nil {
suite.FailNow(err.Error())
}
suite.WithinDuration(time.Now(), updatedTime, 1*time.Minute)
suite.Equal(report.ActionTakenByAccount.ID, testAccount.ID)
suite.EqualValues(report.ActionTakenComment, actionTakenComment)
suite.EqualValues(report.AssignedAccount.ID, testAccount.ID)
}
func TestReportResolveTestSuite(t *testing.T) {
suite.Run(t, &ReportResolveTestSuite{})
}

View File

@ -0,0 +1,184 @@
/*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package admin
import (
"fmt"
"net/http"
"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"
)
// ReportsGETHandler swagger:operation GET /api/v1/admin/reports adminReports
//
// View user moderation reports.
//
// The reports will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer).
//
// The next and previous queries can be parsed from the returned Link header.
//
// Example:
//
// ```
// <https://example.org/api/v1/admin/reports?limit=20&max_id=01FC0SKA48HNSVR6YKZCQGS2V8>; rel="next", <https://example.org/api/v1/admin/reports?limit=20&min_id=01FC0SKW5JK2Q4EVAV2B462YY0>; rel="prev"
// ````
//
// ---
// tags:
// - admin
//
// produces:
// - application/json
//
// parameters:
// -
// name: resolved
// type: boolean
// description: >-
// If set to true, only resolved reports will be returned.
// If false, only unresolved reports will be returned.
// If unset, reports will not be filtered on their resolved status.
// in: query
// -
// name: account_id
// type: string
// description: Return only reports created by the given account id.
// in: query
// -
// name: target_account_id
// type: string
// description: Return only reports that target the given account id.
// in: query
// -
// name: max_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.
// in: query
// -
// name: since_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.
// in: query
// -
// name: min_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.
// 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.
// default: 20
// in: query
//
// security:
// - OAuth2 Bearer:
// - admin
//
// responses:
// '200':
// name: reports
// description: Array of reports.
// schema:
// type: array
// items:
// "$ref": "#/definitions/adminReport"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) ReportsGETHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet)
return
}
if !*authed.User.Admin {
err := fmt.Errorf("user %s not an admin", authed.User.ID)
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet)
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.InstanceGet)
return
}
resolved = &i
}
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.InstanceGet)
return
}
// normalize
if i <= 0 {
i = 1
} else if i >= 100 {
i = 100
}
limit = i
}
resp, errWithCode := m.processor.AdminReportsGet(c.Request.Context(), authed, resolved, c.Query(AccountIDKey), c.Query(TargetAccountIDKey), c.Query(MaxIDKey), c.Query(SinceIDKey), c.Query(MinIDKey), limit)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet)
return
}
if resp.LinkHeader != "" {
c.Header("Link", resp.LinkHeader)
}
c.JSON(http.StatusOK, resp.Items)
}

View File

@ -0,0 +1,905 @@
/*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package admin_test
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/admin"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/testrig"
)
type ReportsGetTestSuite struct {
AdminStandardTestSuite
}
func (suite *ReportsGetTestSuite) getReports(
account *gtsmodel.Account,
token *gtsmodel.Token,
user *gtsmodel.User,
expectedHTTPStatus int,
expectedBody string,
resolved *bool,
accountID string,
targetAccountID string,
maxID string,
sinceID string,
minID string,
limit int,
) ([]*apimodel.AdminReport, string, error) {
// instantiate recorder + test context
recorder := httptest.NewRecorder()
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
ctx.Set(oauth.SessionAuthorizedAccount, account)
ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(token))
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
ctx.Set(oauth.SessionAuthorizedUser, user)
// create the request URI
requestPath := admin.ReportsPath + "?" + admin.LimitKey + "=" + strconv.Itoa(limit)
if resolved != nil {
requestPath = requestPath + "&" + admin.ResolvedKey + "=" + strconv.FormatBool(*resolved)
}
if accountID != "" {
requestPath = requestPath + "&" + admin.AccountIDKey + "=" + accountID
}
if targetAccountID != "" {
requestPath = requestPath + "&" + admin.TargetAccountIDKey + "=" + targetAccountID
}
if maxID != "" {
requestPath = requestPath + "&" + admin.MaxIDKey + "=" + maxID
}
if sinceID != "" {
requestPath = requestPath + "&" + admin.SinceIDKey + "=" + sinceID
}
if minID != "" {
requestPath = requestPath + "&" + admin.MinIDKey + "=" + minID
}
baseURI := config.GetProtocol() + "://" + config.GetHost()
requestURI := baseURI + "/api/" + requestPath
// create the request
ctx.Request = httptest.NewRequest(http.MethodGet, requestURI, nil)
ctx.Request.Header.Set("accept", "application/json")
// trigger the handler
suite.adminModule.ReportsGETHandler(ctx)
// read the response
result := recorder.Result()
defer result.Body.Close()
b, err := ioutil.ReadAll(result.Body)
if err != nil {
return nil, "", err
}
errs := gtserror.MultiError{}
if resultCode := recorder.Code; expectedHTTPStatus != resultCode {
errs = append(errs, fmt.Sprintf("expected %d got %d", expectedHTTPStatus, resultCode))
}
// if we got an expected body, return early
if expectedBody != "" {
if string(b) != expectedBody {
errs = append(errs, fmt.Sprintf("expected %s got %s", expectedBody, string(b)))
}
return nil, "", errs.Combine()
}
resp := []*apimodel.AdminReport{}
if err := json.Unmarshal(b, &resp); err != nil {
return nil, "", err
}
return resp, result.Header.Get("Link"), nil
}
func (suite *ReportsGetTestSuite) TestReportsGet1() {
testAccount := suite.testAccounts["admin_account"]
testToken := suite.testTokens["admin_account"]
testUser := suite.testUsers["admin_account"]
reports, link, err := suite.getReports(testAccount, testToken, testUser, http.StatusOK, "", nil, "", "", "", "", "", 20)
suite.NoError(err)
suite.NotEmpty(reports)
b, err := json.MarshalIndent(&reports, "", " ")
suite.NoError(err)
suite.Equal(`[
{
"id": "01GP3DFY9XQ1TJMZT5BGAZPXX7",
"action_taken": true,
"action_taken_at": "2022-05-15T15:01:56.000Z",
"category": "other",
"comment": "this is a turtle, not a person, therefore should not be a poster",
"forwarded": true,
"created_at": "2022-05-15T14:20:12.000Z",
"updated_at": "2022-05-15T14:20:12.000Z",
"account": {
"id": "01F8MH5ZK5VRH73AKHQM6Y9VNX",
"username": "foss_satan",
"domain": "fossbros-anonymous.io",
"created_at": "2021-09-26T10:52:36.000Z",
"email": "",
"ip": null,
"ips": [],
"locale": "",
"invite_request": null,
"role": "user",
"confirmed": false,
"approved": false,
"disabled": false,
"silenced": false,
"suspended": false,
"account": {
"id": "01F8MH5ZK5VRH73AKHQM6Y9VNX",
"username": "foss_satan",
"acct": "foss_satan@fossbros-anonymous.io",
"display_name": "big gerald",
"locked": false,
"bot": false,
"created_at": "2021-09-26T10:52:36.000Z",
"note": "i post about like, i dunno, stuff, or whatever!!!!",
"url": "http://fossbros-anonymous.io/@foss_satan",
"avatar": "",
"avatar_static": "",
"header": "http://localhost:8080/assets/default_header.png",
"header_static": "http://localhost:8080/assets/default_header.png",
"followers_count": 0,
"following_count": 0,
"statuses_count": 1,
"last_status_at": "2021-09-20T10:40:37.000Z",
"emojis": [],
"fields": []
}
},
"target_account": {
"id": "01F8MH5NBDF2MV7CTC4Q5128HF",
"username": "1happyturtle",
"domain": null,
"created_at": "2022-06-04T13:12:00.000Z",
"email": "tortle.dude@example.org",
"ip": "118.44.18.196",
"ips": [],
"locale": "en",
"invite_request": "",
"role": "user",
"confirmed": true,
"approved": true,
"disabled": false,
"silenced": false,
"suspended": false,
"account": {
"id": "01F8MH5NBDF2MV7CTC4Q5128HF",
"username": "1happyturtle",
"acct": "1happyturtle",
"display_name": "happy little turtle :3",
"locked": true,
"bot": false,
"created_at": "2022-06-04T13:12:00.000Z",
"note": "\u003cp\u003ei post about things that concern me\u003c/p\u003e",
"url": "http://localhost:8080/@1happyturtle",
"avatar": "",
"avatar_static": "",
"header": "http://localhost:8080/assets/default_header.png",
"header_static": "http://localhost:8080/assets/default_header.png",
"followers_count": 1,
"following_count": 1,
"statuses_count": 7,
"last_status_at": "2021-10-20T10:40:37.000Z",
"emojis": [],
"fields": [],
"role": "user"
},
"created_by_application_id": "01F8MGY43H3N2C8EWPR2FPYEXG"
},
"assigned_account": {
"id": "01F8MH17FWEB39HZJ76B6VXSKF",
"username": "admin",
"domain": null,
"created_at": "2022-05-17T13:10:59.000Z",
"email": "admin@example.org",
"ip": "89.122.255.1",
"ips": [],
"locale": "en",
"invite_request": "",
"role": "admin",
"confirmed": true,
"approved": true,
"disabled": false,
"silenced": false,
"suspended": false,
"account": {
"id": "01F8MH17FWEB39HZJ76B6VXSKF",
"username": "admin",
"acct": "admin",
"display_name": "",
"locked": false,
"bot": false,
"created_at": "2022-05-17T13:10:59.000Z",
"note": "",
"url": "http://localhost:8080/@admin",
"avatar": "",
"avatar_static": "",
"header": "http://localhost:8080/assets/default_header.png",
"header_static": "http://localhost:8080/assets/default_header.png",
"followers_count": 1,
"following_count": 1,
"statuses_count": 4,
"last_status_at": "2021-10-20T10:41:37.000Z",
"emojis": [],
"fields": [],
"enable_rss": true,
"role": "admin"
},
"created_by_application_id": "01F8MGXQRHYF5QPMTMXP78QC2F"
},
"action_taken_by_account": {
"id": "01F8MH17FWEB39HZJ76B6VXSKF",
"username": "admin",
"domain": null,
"created_at": "2022-05-17T13:10:59.000Z",
"email": "admin@example.org",
"ip": "89.122.255.1",
"ips": [],
"locale": "en",
"invite_request": "",
"role": "admin",
"confirmed": true,
"approved": true,
"disabled": false,
"silenced": false,
"suspended": false,
"account": {
"id": "01F8MH17FWEB39HZJ76B6VXSKF",
"username": "admin",
"acct": "admin",
"display_name": "",
"locked": false,
"bot": false,
"created_at": "2022-05-17T13:10:59.000Z",
"note": "",
"url": "http://localhost:8080/@admin",
"avatar": "",
"avatar_static": "",
"header": "http://localhost:8080/assets/default_header.png",
"header_static": "http://localhost:8080/assets/default_header.png",
"followers_count": 1,
"following_count": 1,
"statuses_count": 4,
"last_status_at": "2021-10-20T10:41:37.000Z",
"emojis": [],
"fields": [],
"enable_rss": true,
"role": "admin"
},
"created_by_application_id": "01F8MGXQRHYF5QPMTMXP78QC2F"
},
"statuses": [],
"rule_ids": [],
"action_taken_comment": "user was warned not to be a turtle anymore"
},
{
"id": "01GP3AWY4CRDVRNZKW0TEAMB5R",
"action_taken": false,
"action_taken_at": null,
"category": "other",
"comment": "dark souls sucks, please yeet this nerd",
"forwarded": true,
"created_at": "2022-05-14T10:20:03.000Z",
"updated_at": "2022-05-14T10:20:03.000Z",
"account": {
"id": "01F8MH5NBDF2MV7CTC4Q5128HF",
"username": "1happyturtle",
"domain": null,
"created_at": "2022-06-04T13:12:00.000Z",
"email": "tortle.dude@example.org",
"ip": "118.44.18.196",
"ips": [],
"locale": "en",
"invite_request": "",
"role": "user",
"confirmed": true,
"approved": true,
"disabled": false,
"silenced": false,
"suspended": false,
"account": {
"id": "01F8MH5NBDF2MV7CTC4Q5128HF",
"username": "1happyturtle",
"acct": "1happyturtle",
"display_name": "happy little turtle :3",
"locked": true,
"bot": false,
"created_at": "2022-06-04T13:12:00.000Z",
"note": "\u003cp\u003ei post about things that concern me\u003c/p\u003e",
"url": "http://localhost:8080/@1happyturtle",
"avatar": "",
"avatar_static": "",
"header": "http://localhost:8080/assets/default_header.png",
"header_static": "http://localhost:8080/assets/default_header.png",
"followers_count": 1,
"following_count": 1,
"statuses_count": 7,
"last_status_at": "2021-10-20T10:40:37.000Z",
"emojis": [],
"fields": [],
"role": "user"
},
"created_by_application_id": "01F8MGY43H3N2C8EWPR2FPYEXG"
},
"target_account": {
"id": "01F8MH5ZK5VRH73AKHQM6Y9VNX",
"username": "foss_satan",
"domain": "fossbros-anonymous.io",
"created_at": "2021-09-26T10:52:36.000Z",
"email": "",
"ip": null,
"ips": [],
"locale": "",
"invite_request": null,
"role": "user",
"confirmed": false,
"approved": false,
"disabled": false,
"silenced": false,
"suspended": false,
"account": {
"id": "01F8MH5ZK5VRH73AKHQM6Y9VNX",
"username": "foss_satan",
"acct": "foss_satan@fossbros-anonymous.io",
"display_name": "big gerald",
"locked": false,
"bot": false,
"created_at": "2021-09-26T10:52:36.000Z",
"note": "i post about like, i dunno, stuff, or whatever!!!!",
"url": "http://fossbros-anonymous.io/@foss_satan",
"avatar": "",
"avatar_static": "",
"header": "http://localhost:8080/assets/default_header.png",
"header_static": "http://localhost:8080/assets/default_header.png",
"followers_count": 0,
"following_count": 0,
"statuses_count": 1,
"last_status_at": "2021-09-20T10:40:37.000Z",
"emojis": [],
"fields": []
}
},
"assigned_account": null,
"action_taken_by_account": null,
"statuses": [
{
"id": "01FVW7JHQFSFK166WWKR8CBA6M",
"created_at": "2021-09-20T10:40:37.000Z",
"in_reply_to_id": null,
"in_reply_to_account_id": null,
"sensitive": false,
"spoiler_text": "",
"visibility": "unlisted",
"language": "en",
"uri": "http://fossbros-anonymous.io/users/foss_satan/statuses/01FVW7JHQFSFK166WWKR8CBA6M",
"url": "http://fossbros-anonymous.io/@foss_satan/statuses/01FVW7JHQFSFK166WWKR8CBA6M",
"replies_count": 0,
"reblogs_count": 0,
"favourites_count": 0,
"favourited": false,
"reblogged": false,
"muted": false,
"bookmarked": false,
"pinned": false,
"content": "dark souls status bot: \"thoughts of dog\"",
"reblog": null,
"account": {
"id": "01F8MH5ZK5VRH73AKHQM6Y9VNX",
"username": "foss_satan",
"acct": "foss_satan@fossbros-anonymous.io",
"display_name": "big gerald",
"locked": false,
"bot": false,
"created_at": "2021-09-26T10:52:36.000Z",
"note": "i post about like, i dunno, stuff, or whatever!!!!",
"url": "http://fossbros-anonymous.io/@foss_satan",
"avatar": "",
"avatar_static": "",
"header": "http://localhost:8080/assets/default_header.png",
"header_static": "http://localhost:8080/assets/default_header.png",
"followers_count": 0,
"following_count": 0,
"statuses_count": 1,
"last_status_at": "2021-09-20T10:40:37.000Z",
"emojis": [],
"fields": []
},
"media_attachments": [
{
"id": "01FVW7RXPQ8YJHTEXYPE7Q8ZY0",
"type": "image",
"url": "http://localhost:8080/fileserver/01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/original/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpg",
"text_url": "http://localhost:8080/fileserver/01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/original/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpg",
"preview_url": "http://localhost:8080/fileserver/01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/small/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpg",
"remote_url": "http://fossbros-anonymous.io/attachments/original/13bbc3f8-2b5e-46ea-9531-40b4974d9912.jpg",
"preview_remote_url": "http://fossbros-anonymous.io/attachments/small/a499f55b-2d1e-4acd-98d2-1ac2ba6d79b9.jpg",
"meta": {
"original": {
"width": 472,
"height": 291,
"size": "472x291",
"aspect": 1.6219932
},
"small": {
"width": 472,
"height": 291,
"size": "472x291",
"aspect": 1.6219932
},
"focus": {
"x": 0,
"y": 0
}
},
"description": "tweet from thoughts of dog: i drank. all the water. in my bowl. earlier. but just now. i returned. to the same bowl. and it was. full again.. the bowl. is haunted",
"blurhash": "LARysgM_IU_3~pD%M_Rj_39FIAt6"
}
],
"mentions": [],
"tags": [],
"emojis": [],
"card": null,
"poll": null
}
],
"rule_ids": [],
"action_taken_comment": null
}
]`, string(b))
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) TestReportsGet2() {
testAccount := suite.testAccounts["admin_account"]
testToken := suite.testTokens["admin_account"]
testUser := suite.testUsers["admin_account"]
account := suite.testAccounts["local_account_2"]
reports, link, err := suite.getReports(testAccount, testToken, testUser, http.StatusOK, "", nil, account.ID, "", "", "", "", 20)
suite.NoError(err)
suite.NotEmpty(reports)
b, err := json.MarshalIndent(&reports, "", " ")
suite.NoError(err)
suite.Equal(`[
{
"id": "01GP3AWY4CRDVRNZKW0TEAMB5R",
"action_taken": false,
"action_taken_at": null,
"category": "other",
"comment": "dark souls sucks, please yeet this nerd",
"forwarded": true,
"created_at": "2022-05-14T10:20:03.000Z",
"updated_at": "2022-05-14T10:20:03.000Z",
"account": {
"id": "01F8MH5NBDF2MV7CTC4Q5128HF",
"username": "1happyturtle",
"domain": null,
"created_at": "2022-06-04T13:12:00.000Z",
"email": "tortle.dude@example.org",
"ip": "118.44.18.196",
"ips": [],
"locale": "en",
"invite_request": "",
"role": "user",
"confirmed": true,
"approved": true,
"disabled": false,
"silenced": false,
"suspended": false,
"account": {
"id": "01F8MH5NBDF2MV7CTC4Q5128HF",
"username": "1happyturtle",
"acct": "1happyturtle",
"display_name": "happy little turtle :3",
"locked": true,
"bot": false,
"created_at": "2022-06-04T13:12:00.000Z",
"note": "\u003cp\u003ei post about things that concern me\u003c/p\u003e",
"url": "http://localhost:8080/@1happyturtle",
"avatar": "",
"avatar_static": "",
"header": "http://localhost:8080/assets/default_header.png",
"header_static": "http://localhost:8080/assets/default_header.png",
"followers_count": 1,
"following_count": 1,
"statuses_count": 7,
"last_status_at": "2021-10-20T10:40:37.000Z",
"emojis": [],
"fields": [],
"role": "user"
},
"created_by_application_id": "01F8MGY43H3N2C8EWPR2FPYEXG"
},
"target_account": {
"id": "01F8MH5ZK5VRH73AKHQM6Y9VNX",
"username": "foss_satan",
"domain": "fossbros-anonymous.io",
"created_at": "2021-09-26T10:52:36.000Z",
"email": "",
"ip": null,
"ips": [],
"locale": "",
"invite_request": null,
"role": "user",
"confirmed": false,
"approved": false,
"disabled": false,
"silenced": false,
"suspended": false,
"account": {
"id": "01F8MH5ZK5VRH73AKHQM6Y9VNX",
"username": "foss_satan",
"acct": "foss_satan@fossbros-anonymous.io",
"display_name": "big gerald",
"locked": false,
"bot": false,
"created_at": "2021-09-26T10:52:36.000Z",
"note": "i post about like, i dunno, stuff, or whatever!!!!",
"url": "http://fossbros-anonymous.io/@foss_satan",
"avatar": "",
"avatar_static": "",
"header": "http://localhost:8080/assets/default_header.png",
"header_static": "http://localhost:8080/assets/default_header.png",
"followers_count": 0,
"following_count": 0,
"statuses_count": 1,
"last_status_at": "2021-09-20T10:40:37.000Z",
"emojis": [],
"fields": []
}
},
"assigned_account": null,
"action_taken_by_account": null,
"statuses": [
{
"id": "01FVW7JHQFSFK166WWKR8CBA6M",
"created_at": "2021-09-20T10:40:37.000Z",
"in_reply_to_id": null,
"in_reply_to_account_id": null,
"sensitive": false,
"spoiler_text": "",
"visibility": "unlisted",
"language": "en",
"uri": "http://fossbros-anonymous.io/users/foss_satan/statuses/01FVW7JHQFSFK166WWKR8CBA6M",
"url": "http://fossbros-anonymous.io/@foss_satan/statuses/01FVW7JHQFSFK166WWKR8CBA6M",
"replies_count": 0,
"reblogs_count": 0,
"favourites_count": 0,
"favourited": false,
"reblogged": false,
"muted": false,
"bookmarked": false,
"pinned": false,
"content": "dark souls status bot: \"thoughts of dog\"",
"reblog": null,
"account": {
"id": "01F8MH5ZK5VRH73AKHQM6Y9VNX",
"username": "foss_satan",
"acct": "foss_satan@fossbros-anonymous.io",
"display_name": "big gerald",
"locked": false,
"bot": false,
"created_at": "2021-09-26T10:52:36.000Z",
"note": "i post about like, i dunno, stuff, or whatever!!!!",
"url": "http://fossbros-anonymous.io/@foss_satan",
"avatar": "",
"avatar_static": "",
"header": "http://localhost:8080/assets/default_header.png",
"header_static": "http://localhost:8080/assets/default_header.png",
"followers_count": 0,
"following_count": 0,
"statuses_count": 1,
"last_status_at": "2021-09-20T10:40:37.000Z",
"emojis": [],
"fields": []
},
"media_attachments": [
{
"id": "01FVW7RXPQ8YJHTEXYPE7Q8ZY0",
"type": "image",
"url": "http://localhost:8080/fileserver/01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/original/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpg",
"text_url": "http://localhost:8080/fileserver/01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/original/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpg",
"preview_url": "http://localhost:8080/fileserver/01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/small/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpg",
"remote_url": "http://fossbros-anonymous.io/attachments/original/13bbc3f8-2b5e-46ea-9531-40b4974d9912.jpg",
"preview_remote_url": "http://fossbros-anonymous.io/attachments/small/a499f55b-2d1e-4acd-98d2-1ac2ba6d79b9.jpg",
"meta": {
"original": {
"width": 472,
"height": 291,
"size": "472x291",
"aspect": 1.6219932
},
"small": {
"width": 472,
"height": 291,
"size": "472x291",
"aspect": 1.6219932
},
"focus": {
"x": 0,
"y": 0
}
},
"description": "tweet from thoughts of dog: i drank. all the water. in my bowl. earlier. but just now. i returned. to the same bowl. and it was. full again.. the bowl. is haunted",
"blurhash": "LARysgM_IU_3~pD%M_Rj_39FIAt6"
}
],
"mentions": [],
"tags": [],
"emojis": [],
"card": null,
"poll": null
}
],
"rule_ids": [],
"action_taken_comment": null
}
]`, 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)
}
func (suite *ReportsGetTestSuite) TestReportsGet3() {
testAccount := suite.testAccounts["admin_account"]
testToken := suite.testTokens["admin_account"]
testUser := suite.testUsers["admin_account"]
targetAccount := suite.testAccounts["remote_account_1"]
reports, link, err := suite.getReports(testAccount, testToken, testUser, http.StatusOK, "", nil, "", targetAccount.ID, "", "", "", 20)
suite.NoError(err)
suite.NotEmpty(reports)
b, err := json.MarshalIndent(&reports, "", " ")
suite.NoError(err)
suite.Equal(`[
{
"id": "01GP3AWY4CRDVRNZKW0TEAMB5R",
"action_taken": false,
"action_taken_at": null,
"category": "other",
"comment": "dark souls sucks, please yeet this nerd",
"forwarded": true,
"created_at": "2022-05-14T10:20:03.000Z",
"updated_at": "2022-05-14T10:20:03.000Z",
"account": {
"id": "01F8MH5NBDF2MV7CTC4Q5128HF",
"username": "1happyturtle",
"domain": null,
"created_at": "2022-06-04T13:12:00.000Z",
"email": "tortle.dude@example.org",
"ip": "118.44.18.196",
"ips": [],
"locale": "en",
"invite_request": "",
"role": "user",
"confirmed": true,
"approved": true,
"disabled": false,
"silenced": false,
"suspended": false,
"account": {
"id": "01F8MH5NBDF2MV7CTC4Q5128HF",
"username": "1happyturtle",
"acct": "1happyturtle",
"display_name": "happy little turtle :3",
"locked": true,
"bot": false,
"created_at": "2022-06-04T13:12:00.000Z",
"note": "\u003cp\u003ei post about things that concern me\u003c/p\u003e",
"url": "http://localhost:8080/@1happyturtle",
"avatar": "",
"avatar_static": "",
"header": "http://localhost:8080/assets/default_header.png",
"header_static": "http://localhost:8080/assets/default_header.png",
"followers_count": 1,
"following_count": 1,
"statuses_count": 7,
"last_status_at": "2021-10-20T10:40:37.000Z",
"emojis": [],
"fields": [],
"role": "user"
},
"created_by_application_id": "01F8MGY43H3N2C8EWPR2FPYEXG"
},
"target_account": {
"id": "01F8MH5ZK5VRH73AKHQM6Y9VNX",
"username": "foss_satan",
"domain": "fossbros-anonymous.io",
"created_at": "2021-09-26T10:52:36.000Z",
"email": "",
"ip": null,
"ips": [],
"locale": "",
"invite_request": null,
"role": "user",
"confirmed": false,
"approved": false,
"disabled": false,
"silenced": false,
"suspended": false,
"account": {
"id": "01F8MH5ZK5VRH73AKHQM6Y9VNX",
"username": "foss_satan",
"acct": "foss_satan@fossbros-anonymous.io",
"display_name": "big gerald",
"locked": false,
"bot": false,
"created_at": "2021-09-26T10:52:36.000Z",
"note": "i post about like, i dunno, stuff, or whatever!!!!",
"url": "http://fossbros-anonymous.io/@foss_satan",
"avatar": "",
"avatar_static": "",
"header": "http://localhost:8080/assets/default_header.png",
"header_static": "http://localhost:8080/assets/default_header.png",
"followers_count": 0,
"following_count": 0,
"statuses_count": 1,
"last_status_at": "2021-09-20T10:40:37.000Z",
"emojis": [],
"fields": []
}
},
"assigned_account": null,
"action_taken_by_account": null,
"statuses": [
{
"id": "01FVW7JHQFSFK166WWKR8CBA6M",
"created_at": "2021-09-20T10:40:37.000Z",
"in_reply_to_id": null,
"in_reply_to_account_id": null,
"sensitive": false,
"spoiler_text": "",
"visibility": "unlisted",
"language": "en",
"uri": "http://fossbros-anonymous.io/users/foss_satan/statuses/01FVW7JHQFSFK166WWKR8CBA6M",
"url": "http://fossbros-anonymous.io/@foss_satan/statuses/01FVW7JHQFSFK166WWKR8CBA6M",
"replies_count": 0,
"reblogs_count": 0,
"favourites_count": 0,
"favourited": false,
"reblogged": false,
"muted": false,
"bookmarked": false,
"pinned": false,
"content": "dark souls status bot: \"thoughts of dog\"",
"reblog": null,
"account": {
"id": "01F8MH5ZK5VRH73AKHQM6Y9VNX",
"username": "foss_satan",
"acct": "foss_satan@fossbros-anonymous.io",
"display_name": "big gerald",
"locked": false,
"bot": false,
"created_at": "2021-09-26T10:52:36.000Z",
"note": "i post about like, i dunno, stuff, or whatever!!!!",
"url": "http://fossbros-anonymous.io/@foss_satan",
"avatar": "",
"avatar_static": "",
"header": "http://localhost:8080/assets/default_header.png",
"header_static": "http://localhost:8080/assets/default_header.png",
"followers_count": 0,
"following_count": 0,
"statuses_count": 1,
"last_status_at": "2021-09-20T10:40:37.000Z",
"emojis": [],
"fields": []
},
"media_attachments": [
{
"id": "01FVW7RXPQ8YJHTEXYPE7Q8ZY0",
"type": "image",
"url": "http://localhost:8080/fileserver/01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/original/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpg",
"text_url": "http://localhost:8080/fileserver/01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/original/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpg",
"preview_url": "http://localhost:8080/fileserver/01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/small/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpg",
"remote_url": "http://fossbros-anonymous.io/attachments/original/13bbc3f8-2b5e-46ea-9531-40b4974d9912.jpg",
"preview_remote_url": "http://fossbros-anonymous.io/attachments/small/a499f55b-2d1e-4acd-98d2-1ac2ba6d79b9.jpg",
"meta": {
"original": {
"width": 472,
"height": 291,
"size": "472x291",
"aspect": 1.6219932
},
"small": {
"width": 472,
"height": 291,
"size": "472x291",
"aspect": 1.6219932
},
"focus": {
"x": 0,
"y": 0
}
},
"description": "tweet from thoughts of dog: i drank. all the water. in my bowl. earlier. but just now. i returned. to the same bowl. and it was. full again.. the bowl. is haunted",
"blurhash": "LARysgM_IU_3~pD%M_Rj_39FIAt6"
}
],
"mentions": [],
"tags": [],
"emojis": [],
"card": null,
"poll": null
}
],
"rule_ids": [],
"action_taken_comment": null
}
]`, string(b))
suite.Equal(`<http://localhost:8080/api/v1/admin/reports?limit=20&max_id=01GP3AWY4CRDVRNZKW0TEAMB5R&target_account_id=01F8MH5ZK5VRH73AKHQM6Y9VNX>; rel="next", <http://localhost:8080/api/v1/admin/reports?limit=20&min_id=01GP3AWY4CRDVRNZKW0TEAMB5R&target_account_id=01F8MH5ZK5VRH73AKHQM6Y9VNX>; rel="prev"`, link)
}
func (suite *ReportsGetTestSuite) TestReportsGet4() {
testAccount := suite.testAccounts["admin_account"]
testToken := suite.testTokens["admin_account"]
testUser := suite.testUsers["admin_account"]
resolved := testrig.FalseBool()
targetAccount := suite.testAccounts["local_account_2"]
reports, link, err := suite.getReports(testAccount, testToken, testUser, http.StatusOK, "", resolved, "", targetAccount.ID, "", "", "", 20)
suite.NoError(err)
suite.Empty(reports)
b, err := json.MarshalIndent(&reports, "", " ")
suite.NoError(err)
suite.Equal(`[]`, string(b))
suite.Empty(link)
}
func (suite *ReportsGetTestSuite) TestReportsGet6() {
testAccount := suite.testAccounts["local_account_1"]
testToken := suite.testTokens["local_account_1"]
testUser := suite.testUsers["local_account_1"]
reports, _, err := suite.getReports(testAccount, testToken, testUser, http.StatusForbidden, `{"error":"Forbidden: user 01F8MGVGPHQ2D3P3X0454H54Z5 not an admin"}`, nil, "", "", "", "", "", 20)
suite.NoError(err)
suite.Empty(reports)
}
func TestReportsGetTestSuite(t *testing.T) {
suite.Run(t, &ReportsGetTestSuite{})
}

View File

@ -19,23 +19,42 @@
package model
// AdminAccountInfo models the admin view of an account's details.
//
// swagger:model adminAccountInfo
type AdminAccountInfo struct {
// The ID of the account in the database.
// example: 01GQ4PHNT622DQ9X95XQX4KKNR
ID string `json:"id"`
// The username of the account.
// example: dril
Username string `json:"username"`
// The domain of the account.
Domain string `json:"domain"`
// Null for local accounts.
// example: example.org
Domain *string `json:"domain"`
// When the account was first discovered. (ISO 8601 Datetime)
// example: 2021-07-30T09:20:25+00:00
CreatedAt string `json:"created_at"`
// The email address associated with the account.
// Empty string for remote accounts or accounts with
// no known email address.
// example: someone@somewhere.com
Email string `json:"email"`
// The IP address last used to login to this account.
IP string `json:"ip"`
// Null if not known.
// example: 192.0.2.1
IP *string `json:"ip"`
// All known IP addresses associated with this account.
// NOT IMPLEMENTED (will always be empty array).
// example: []
IPs []interface{} `json:"ips"`
// The locale of the account. (ISO 639 Part 1 two-letter language code)
// example: en
Locale string `json:"locale"`
// Invite request text
InviteRequest string `json:"invite_request"`
// The reason given when requesting an invite.
// Null if not known / remote account.
// example: Pleaaaaaaaaaaaaaaase!!
InviteRequest *string `json:"invite_request"`
// The current role of the account.
Role string `json:"role"`
// Whether the account has confirmed their email address.
@ -53,12 +72,67 @@ type AdminAccountInfo struct {
// The ID of the application that created this account.
CreatedByApplicationID string `json:"created_by_application_id,omitempty"`
// The ID of the account that invited this user
InvitedByAccountID string `json:"invited_by_account_id"`
InvitedByAccountID string `json:"invited_by_account_id,omitempty"`
}
// AdminReportInfo models the admin view of a report.
type AdminReportInfo struct {
Report
// AdminReport models the admin view of a report.
//
// swagger:model adminReport
type AdminReport struct {
// ID of the report.
// example: 01FBVD42CQ3ZEEVMW180SBX03B
ID string `json:"id"`
// Whether an action has been taken by an admin in response to this report.
// example: false
ActionTaken bool `json:"action_taken"`
// If an action was taken, at what time was this done? (ISO 8601 Datetime)
// Will be null if not set / no action yet taken.
// example: 2021-07-30T09:20:25+00:00
ActionTakenAt *string `json:"action_taken_at"`
// Under what category was this report created?
// example: spam
Category string `json:"category"`
// Comment submitted when the report was created.
// Will be empty if no comment was submitted.
// example: This person has been harassing me.
Comment string `json:"comment"`
// Bool to indicate that report should be federated to remote instance.
// example: true
Forwarded bool `json:"forwarded"`
// The date when this report was created (ISO 8601 Datetime).
// example: 2021-07-30T09:20:25+00:00
CreatedAt string `json:"created_at"`
// Time of last action on this report (ISO 8601 Datetime).
// example: 2021-07-30T09:20:25+00:00
UpdatedAt string `json:"updated_at"`
// The account that created the report.
Account *AdminAccountInfo `json:"account"`
// Account that was reported.
TargetAccount *AdminAccountInfo `json:"target_account"`
// The account assigned to handle the report.
// Null if no account assigned.
AssignedAccount *AdminAccountInfo `json:"assigned_account"`
// Account that took admin action (if any).
// Null if no action (yet) taken.
ActionTakenByAccount *AdminAccountInfo `json:"action_taken_by_account"`
// Array of statuses that were submitted along with this report.
// Will be empty if no status IDs were submitted with the report.
Statuses []*Status `json:"statuses"`
// Array of rule IDs that were submitted along with this report.
// NOT IMPLEMENTED, will always be empty array.
Rules []interface{} `json:"rule_ids"`
// If an action was taken, what comment was made by the admin on the taken action?
// Will be null if not set / no action yet taken.
// example: Account was suspended.
ActionTakenComment *string `json:"action_taken_comment"`
}
// AdminReportResolveRequest can be submitted along with a POST to /api/v1/admin/reports/{id}/resolve
//
// swagger:ignore
type AdminReportResolveRequest struct {
// Comment to show to the creator of the report when an admin marks it as resolved.
ActionTakenComment *string `form:"action_taken_comment" json:"action_taken_comment" xml:"action_taken_comment"`
}
// AdminEmoji models the admin view of a custom emoji.

View File

@ -38,7 +38,7 @@ type Report struct {
// If an action was taken, what comment was made by the admin on the taken action?
// Will be null if not set / no action yet taken.
// example: Account was suspended.
ActionComment *string `json:"action_taken_comment"`
ActionTakenComment *string `json:"action_taken_comment"`
// Under what category was this report created?
// example: spam
Category string `json:"category"`

View File

@ -81,3 +81,15 @@ func (p *processor) AdminMediaPrune(ctx context.Context, mediaRemoteCacheDays in
func (p *processor) AdminMediaRefetch(ctx context.Context, authed *oauth.Auth, domain string) gtserror.WithCode {
return p.adminProcessor.MediaRefetch(ctx, authed.Account, domain)
}
func (p *processor) AdminReportsGet(ctx context.Context, authed *oauth.Auth, resolved *bool, accountID string, targetAccountID string, maxID string, sinceID string, minID string, limit int) (*apimodel.PageableResponse, gtserror.WithCode) {
return p.adminProcessor.ReportsGet(ctx, authed.Account, resolved, accountID, targetAccountID, maxID, sinceID, minID, limit)
}
func (p *processor) AdminReportGet(ctx context.Context, authed *oauth.Auth, id string) (*apimodel.AdminReport, gtserror.WithCode) {
return p.adminProcessor.ReportGet(ctx, authed.Account, id)
}
func (p *processor) AdminReportResolve(ctx context.Context, authed *oauth.Auth, id string, actionTakenComment *string) (*apimodel.AdminReport, gtserror.WithCode) {
return p.adminProcessor.ReportResolve(ctx, authed.Account, id, actionTakenComment)
}

View File

@ -50,6 +50,9 @@ type Processor interface {
EmojiCategoriesGet(ctx context.Context) ([]*apimodel.EmojiCategory, gtserror.WithCode)
MediaPrune(ctx context.Context, mediaRemoteCacheDays int) gtserror.WithCode
MediaRefetch(ctx context.Context, requestingAccount *gtsmodel.Account, domain string) gtserror.WithCode
ReportsGet(ctx context.Context, account *gtsmodel.Account, resolved *bool, accountID string, targetAccountID string, maxID string, sinceID string, minID string, limit int) (*apimodel.PageableResponse, gtserror.WithCode)
ReportGet(ctx context.Context, account *gtsmodel.Account, id string) (*apimodel.AdminReport, gtserror.WithCode)
ReportResolve(ctx context.Context, account *gtsmodel.Account, id string, actionTakenComment *string) (*apimodel.AdminReport, gtserror.WithCode)
}
type processor struct {

View File

@ -0,0 +1,45 @@
/*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package admin
import (
"context"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
func (p *processor) ReportGet(ctx context.Context, account *gtsmodel.Account, id string) (*apimodel.AdminReport, gtserror.WithCode) {
report, err := p.db.GetReportByID(ctx, id)
if err != nil {
if err == db.ErrNoEntries {
return nil, gtserror.NewErrorNotFound(err)
}
return nil, gtserror.NewErrorInternalError(err)
}
apimodelReport, err := p.tc.ReportToAdminAPIReport(ctx, report, account)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
return apimodelReport, nil
}

View File

@ -0,0 +1,92 @@
/*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package admin
import (
"context"
"fmt"
"strconv"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
func (p *processor) ReportsGet(
ctx context.Context,
account *gtsmodel.Account,
resolved *bool,
accountID string,
targetAccountID string,
maxID string,
sinceID string,
minID string,
limit int,
) (*apimodel.PageableResponse, gtserror.WithCode) {
reports, err := p.db.GetReports(ctx, resolved, accountID, targetAccountID, maxID, sinceID, minID, limit)
if err != nil {
if err == db.ErrNoEntries {
return util.EmptyPageableResponse(), nil
}
return nil, gtserror.NewErrorInternalError(err)
}
count := len(reports)
items := make([]interface{}, 0, count)
nextMaxIDValue := ""
prevMinIDValue := ""
for i, r := range reports {
item, err := p.tc.ReportToAdminAPIReport(ctx, r, account)
if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting report to api: %s", err))
}
if i == count-1 {
nextMaxIDValue = item.ID
}
if i == 0 {
prevMinIDValue = item.ID
}
items = append(items, item)
}
extraQueryParams := []string{}
if resolved != nil {
extraQueryParams = append(extraQueryParams, "resolved="+strconv.FormatBool(*resolved))
}
if accountID != "" {
extraQueryParams = append(extraQueryParams, "account_id="+accountID)
}
if targetAccountID != "" {
extraQueryParams = append(extraQueryParams, "target_account_id="+targetAccountID)
}
return util.PackagePageableResponse(util.PageableResponseParams{
Items: items,
Path: "/api/v1/admin/reports",
NextMaxIDValue: nextMaxIDValue,
PrevMinIDValue: prevMinIDValue,
Limit: limit,
ExtraQueryParams: extraQueryParams,
})
}

View File

@ -0,0 +1,64 @@
/*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package admin
import (
"context"
"time"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
func (p *processor) ReportResolve(ctx context.Context, account *gtsmodel.Account, id string, actionTakenComment *string) (*apimodel.AdminReport, gtserror.WithCode) {
report, err := p.db.GetReportByID(ctx, id)
if err != nil {
if err == db.ErrNoEntries {
return nil, gtserror.NewErrorNotFound(err)
}
return nil, gtserror.NewErrorInternalError(err)
}
columns := []string{
"action_taken_at",
"action_taken_by_account_id",
}
report.ActionTakenAt = time.Now()
report.ActionTakenByAccountID = account.ID
if actionTakenComment != nil {
report.ActionTaken = *actionTakenComment
columns = append(columns, "action_taken")
}
updatedReport, err := p.db.UpdateReport(ctx, report, columns...)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
apimodelReport, err := p.tc.ReportToAdminAPIReport(ctx, updatedReport, account)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
return apimodelReport, nil
}

View File

@ -140,6 +140,13 @@ type Processor interface {
AdminMediaPrune(ctx context.Context, mediaRemoteCacheDays int) gtserror.WithCode
// AdminMediaRefetch triggers a refetch of remote media for the given domain (or all if domain is empty).
AdminMediaRefetch(ctx context.Context, authed *oauth.Auth, domain string) gtserror.WithCode
// AdminReportsGet returns a list of user moderation reports.
AdminReportsGet(ctx context.Context, authed *oauth.Auth, resolved *bool, accountID string, targetAccountID string, maxID string, sinceID string, minID string, limit int) (*apimodel.PageableResponse, gtserror.WithCode)
// AdminReportGet returns a single user moderation report, specified by id.
AdminReportGet(ctx context.Context, authed *oauth.Auth, id string) (*apimodel.AdminReport, gtserror.WithCode)
// AdminReportResolve marks a single user moderation report as resolved, with the given id.
// actionTakenComment is optional: if set, this will be stored as a comment on the action taken.
AdminReportResolve(ctx context.Context, authed *oauth.Auth, id string, actionTakenComment *string) (*apimodel.AdminReport, gtserror.WithCode)
// AppCreate processes the creation of a new API application
AppCreate(ctx context.Context, authed *oauth.Auth, form *apimodel.ApplicationCreateRequest) (*apimodel.Application, gtserror.WithCode)

View File

@ -89,6 +89,8 @@ type TypeConverter interface {
DomainBlockToAPIDomainBlock(ctx context.Context, b *gtsmodel.DomainBlock, export bool) (*apimodel.DomainBlock, error)
// ReportToAPIReport converts a gts model report into an api model report, for serving at /api/v1/reports
ReportToAPIReport(ctx context.Context, r *gtsmodel.Report) (*apimodel.Report, error)
// ReportToAdminAPIReport converts a gts model report into an admin view report, for serving at /api/v1/admin/reports
ReportToAdminAPIReport(ctx context.Context, r *gtsmodel.Report, requestingAccount *gtsmodel.Account) (*apimodel.AdminReport, error)
/*
INTERNAL (gts) MODEL TO FRONTEND (rss) MODEL

View File

@ -256,6 +256,83 @@ func (c *converter) AccountToAPIAccountBlocked(ctx context.Context, a *gtsmodel.
}, nil
}
func (c *converter) AccountToAdminAPIAccount(ctx context.Context, a *gtsmodel.Account) (*apimodel.AdminAccountInfo, error) {
var (
email string
ip *string
domain *string
locale string
confirmed bool
inviteRequest *string
approved bool
disabled bool
silenced bool
suspended bool
role apimodel.AccountRole = apimodel.AccountRoleUser // assume user by default
createdByApplicationID string
)
// take user-level information if possible
if a.Domain != "" {
domain = &a.Domain
} else {
user, err := c.db.GetUserByAccountID(ctx, a.ID)
if err != nil {
return nil, fmt.Errorf("AccountToAdminAPIAccount: error getting user from database for account id %s: %w", a.ID, err)
}
if user.Email != "" {
email = user.Email
} else {
email = user.UnconfirmedEmail
}
if i := user.CurrentSignInIP.String(); i != "<nil>" {
ip = &i
}
locale = user.Locale
inviteRequest = &user.Account.Reason
if *user.Admin {
role = apimodel.AccountRoleAdmin
} else if *user.Moderator {
role = apimodel.AccountRoleModerator
}
confirmed = !user.ConfirmedAt.IsZero()
approved = *user.Approved
disabled = *user.Disabled
silenced = !user.Account.SilencedAt.IsZero()
suspended = !user.Account.SuspendedAt.IsZero()
createdByApplicationID = user.CreatedByApplicationID
}
apiAccount, err := c.AccountToAPIAccountPublic(ctx, a)
if err != nil {
return nil, fmt.Errorf("AccountToAdminAPIAccount: error converting account to api account for account id %s: %w", a.ID, err)
}
return &apimodel.AdminAccountInfo{
ID: a.ID,
Username: a.Username,
Domain: domain,
CreatedAt: util.FormatISO8601(a.CreatedAt),
Email: email,
IP: ip,
IPs: []interface{}{}, // not implemented,
Locale: locale,
InviteRequest: inviteRequest,
Role: string(role),
Confirmed: confirmed,
Approved: approved,
Disabled: disabled,
Silenced: silenced,
Suspended: suspended,
Account: apiAccount,
CreatedByApplicationID: createdByApplicationID,
InvitedByAccountID: "", // not implemented (yet)
}, nil
}
func (c *converter) AppToAPIAppSensitive(ctx context.Context, a *gtsmodel.Application) (*apimodel.Application, error) {
return &apimodel.Application{
ID: a.ID,
@ -825,7 +902,7 @@ func (c *converter) ReportToAPIReport(ctx context.Context, r *gtsmodel.Report) (
}
if actionComment := r.ActionTaken; actionComment != "" {
report.ActionComment = &actionComment
report.ActionTakenComment = &actionComment
}
if r.TargetAccount == nil {
@ -845,6 +922,93 @@ func (c *converter) ReportToAPIReport(ctx context.Context, r *gtsmodel.Report) (
return report, nil
}
func (c *converter) ReportToAdminAPIReport(ctx context.Context, r *gtsmodel.Report, requestingAccount *gtsmodel.Account) (*apimodel.AdminReport, error) {
var (
err error
actionTakenAt *string
actionTakenComment *string
actionTakenByAccount *apimodel.AdminAccountInfo
)
if !r.ActionTakenAt.IsZero() {
ata := util.FormatISO8601(r.ActionTakenAt)
actionTakenAt = &ata
}
if r.Account == nil {
r.Account, err = c.db.GetAccountByID(ctx, r.AccountID)
if err != nil {
return nil, fmt.Errorf("ReportToAdminAPIReport: error getting account with id %s from the db: %w", r.AccountID, err)
}
}
account, err := c.AccountToAdminAPIAccount(ctx, r.Account)
if err != nil {
return nil, fmt.Errorf("ReportToAdminAPIReport: error converting account with id %s to adminAPIAccount: %w", r.AccountID, err)
}
if r.TargetAccount == nil {
r.TargetAccount, err = c.db.GetAccountByID(ctx, r.TargetAccountID)
if err != nil {
return nil, fmt.Errorf("ReportToAdminAPIReport: error getting target account with id %s from the db: %w", r.TargetAccountID, err)
}
}
targetAccount, err := c.AccountToAdminAPIAccount(ctx, r.TargetAccount)
if err != nil {
return nil, fmt.Errorf("ReportToAdminAPIReport: error converting target account with id %s to adminAPIAccount: %w", r.TargetAccountID, err)
}
if r.ActionTakenByAccountID != "" {
if r.ActionTakenByAccount == nil {
r.ActionTakenByAccount, err = c.db.GetAccountByID(ctx, r.ActionTakenByAccountID)
if err != nil {
return nil, fmt.Errorf("ReportToAdminAPIReport: error getting action taken by account with id %s from the db: %w", r.ActionTakenByAccountID, err)
}
}
actionTakenByAccount, err = c.AccountToAdminAPIAccount(ctx, r.ActionTakenByAccount)
if err != nil {
return nil, fmt.Errorf("ReportToAdminAPIReport: error converting action taken by account with id %s to adminAPIAccount: %w", r.ActionTakenByAccountID, err)
}
}
statuses := make([]*apimodel.Status, 0, len(r.StatusIDs))
if len(r.StatusIDs) != 0 && len(r.Statuses) == 0 {
r.Statuses, err = c.db.GetStatuses(ctx, r.StatusIDs)
if err != nil {
return nil, fmt.Errorf("ReportToAdminAPIReport: error getting statuses from the db: %w", err)
}
}
for _, s := range r.Statuses {
status, err := c.StatusToAPIStatus(ctx, s, requestingAccount)
if err != nil {
return nil, fmt.Errorf("ReportToAdminAPIReport: error converting status with id %s to api status: %w", s.ID, err)
}
statuses = append(statuses, status)
}
if ac := r.ActionTaken; ac != "" {
actionTakenComment = &ac
}
return &apimodel.AdminReport{
ID: r.ID,
ActionTaken: !r.ActionTakenAt.IsZero(),
ActionTakenAt: actionTakenAt,
Category: "other", // todo: only support default 'other' category right now
Comment: r.Comment,
Forwarded: *r.Forwarded,
CreatedAt: util.FormatISO8601(r.CreatedAt),
UpdatedAt: util.FormatISO8601(r.UpdatedAt),
Account: account,
TargetAccount: targetAccount,
AssignedAccount: actionTakenByAccount,
ActionTakenByAccount: actionTakenByAccount,
ActionTakenComment: actionTakenComment,
Statuses: statuses,
Rules: []interface{}{}, // not implemented
}, nil
}
// convertAttachmentsToAPIAttachments will convert a slice of GTS model attachments to frontend API model attachments, falling back to IDs if no GTS models supplied.
func (c *converter) convertAttachmentsToAPIAttachments(ctx context.Context, attachments []*gtsmodel.MediaAttachment, attachmentIDs []string) ([]apimodel.Attachment, error) {
var errs gtserror.MultiError

View File

@ -691,6 +691,372 @@ func (suite *InternalToFrontendTestSuite) TestReportToFrontend2() {
}`, string(b))
}
func (suite *InternalToFrontendTestSuite) TestAdminReportToFrontend1() {
requestingAccount := suite.testAccounts["admin_account"]
adminReport, err := suite.typeconverter.ReportToAdminAPIReport(context.Background(), suite.testReports["remote_account_1_report_local_account_2"], requestingAccount)
suite.NoError(err)
b, err := json.MarshalIndent(adminReport, "", " ")
suite.NoError(err)
suite.Equal(`{
"id": "01GP3DFY9XQ1TJMZT5BGAZPXX7",
"action_taken": true,
"action_taken_at": "2022-05-15T15:01:56.000Z",
"category": "other",
"comment": "this is a turtle, not a person, therefore should not be a poster",
"forwarded": true,
"created_at": "2022-05-15T14:20:12.000Z",
"updated_at": "2022-05-15T14:20:12.000Z",
"account": {
"id": "01F8MH5ZK5VRH73AKHQM6Y9VNX",
"username": "foss_satan",
"domain": "fossbros-anonymous.io",
"created_at": "2021-09-26T10:52:36.000Z",
"email": "",
"ip": null,
"ips": [],
"locale": "",
"invite_request": null,
"role": "user",
"confirmed": false,
"approved": false,
"disabled": false,
"silenced": false,
"suspended": false,
"account": {
"id": "01F8MH5ZK5VRH73AKHQM6Y9VNX",
"username": "foss_satan",
"acct": "foss_satan@fossbros-anonymous.io",
"display_name": "big gerald",
"locked": false,
"bot": false,
"created_at": "2021-09-26T10:52:36.000Z",
"note": "i post about like, i dunno, stuff, or whatever!!!!",
"url": "http://fossbros-anonymous.io/@foss_satan",
"avatar": "",
"avatar_static": "",
"header": "http://localhost:8080/assets/default_header.png",
"header_static": "http://localhost:8080/assets/default_header.png",
"followers_count": 0,
"following_count": 0,
"statuses_count": 1,
"last_status_at": "2021-09-20T10:40:37.000Z",
"emojis": [],
"fields": []
}
},
"target_account": {
"id": "01F8MH5NBDF2MV7CTC4Q5128HF",
"username": "1happyturtle",
"domain": null,
"created_at": "2022-06-04T13:12:00.000Z",
"email": "tortle.dude@example.org",
"ip": "118.44.18.196",
"ips": [],
"locale": "en",
"invite_request": "",
"role": "user",
"confirmed": true,
"approved": true,
"disabled": false,
"silenced": false,
"suspended": false,
"account": {
"id": "01F8MH5NBDF2MV7CTC4Q5128HF",
"username": "1happyturtle",
"acct": "1happyturtle",
"display_name": "happy little turtle :3",
"locked": true,
"bot": false,
"created_at": "2022-06-04T13:12:00.000Z",
"note": "\u003cp\u003ei post about things that concern me\u003c/p\u003e",
"url": "http://localhost:8080/@1happyturtle",
"avatar": "",
"avatar_static": "",
"header": "http://localhost:8080/assets/default_header.png",
"header_static": "http://localhost:8080/assets/default_header.png",
"followers_count": 1,
"following_count": 1,
"statuses_count": 7,
"last_status_at": "2021-10-20T10:40:37.000Z",
"emojis": [],
"fields": [],
"role": "user"
},
"created_by_application_id": "01F8MGY43H3N2C8EWPR2FPYEXG"
},
"assigned_account": {
"id": "01F8MH17FWEB39HZJ76B6VXSKF",
"username": "admin",
"domain": null,
"created_at": "2022-05-17T13:10:59.000Z",
"email": "admin@example.org",
"ip": "89.122.255.1",
"ips": [],
"locale": "en",
"invite_request": "",
"role": "admin",
"confirmed": true,
"approved": true,
"disabled": false,
"silenced": false,
"suspended": false,
"account": {
"id": "01F8MH17FWEB39HZJ76B6VXSKF",
"username": "admin",
"acct": "admin",
"display_name": "",
"locked": false,
"bot": false,
"created_at": "2022-05-17T13:10:59.000Z",
"note": "",
"url": "http://localhost:8080/@admin",
"avatar": "",
"avatar_static": "",
"header": "http://localhost:8080/assets/default_header.png",
"header_static": "http://localhost:8080/assets/default_header.png",
"followers_count": 1,
"following_count": 1,
"statuses_count": 4,
"last_status_at": "2021-10-20T10:41:37.000Z",
"emojis": [],
"fields": [],
"enable_rss": true,
"role": "admin"
},
"created_by_application_id": "01F8MGXQRHYF5QPMTMXP78QC2F"
},
"action_taken_by_account": {
"id": "01F8MH17FWEB39HZJ76B6VXSKF",
"username": "admin",
"domain": null,
"created_at": "2022-05-17T13:10:59.000Z",
"email": "admin@example.org",
"ip": "89.122.255.1",
"ips": [],
"locale": "en",
"invite_request": "",
"role": "admin",
"confirmed": true,
"approved": true,
"disabled": false,
"silenced": false,
"suspended": false,
"account": {
"id": "01F8MH17FWEB39HZJ76B6VXSKF",
"username": "admin",
"acct": "admin",
"display_name": "",
"locked": false,
"bot": false,
"created_at": "2022-05-17T13:10:59.000Z",
"note": "",
"url": "http://localhost:8080/@admin",
"avatar": "",
"avatar_static": "",
"header": "http://localhost:8080/assets/default_header.png",
"header_static": "http://localhost:8080/assets/default_header.png",
"followers_count": 1,
"following_count": 1,
"statuses_count": 4,
"last_status_at": "2021-10-20T10:41:37.000Z",
"emojis": [],
"fields": [],
"enable_rss": true,
"role": "admin"
},
"created_by_application_id": "01F8MGXQRHYF5QPMTMXP78QC2F"
},
"statuses": [],
"rule_ids": [],
"action_taken_comment": "user was warned not to be a turtle anymore"
}`, string(b))
}
func (suite *InternalToFrontendTestSuite) TestAdminReportToFrontend2() {
requestingAccount := suite.testAccounts["admin_account"]
adminReport, err := suite.typeconverter.ReportToAdminAPIReport(context.Background(), suite.testReports["local_account_2_report_remote_account_1"], requestingAccount)
suite.NoError(err)
b, err := json.MarshalIndent(adminReport, "", " ")
suite.NoError(err)
suite.Equal(`{
"id": "01GP3AWY4CRDVRNZKW0TEAMB5R",
"action_taken": false,
"action_taken_at": null,
"category": "other",
"comment": "dark souls sucks, please yeet this nerd",
"forwarded": true,
"created_at": "2022-05-14T10:20:03.000Z",
"updated_at": "2022-05-14T10:20:03.000Z",
"account": {
"id": "01F8MH5NBDF2MV7CTC4Q5128HF",
"username": "1happyturtle",
"domain": null,
"created_at": "2022-06-04T13:12:00.000Z",
"email": "tortle.dude@example.org",
"ip": "118.44.18.196",
"ips": [],
"locale": "en",
"invite_request": "",
"role": "user",
"confirmed": true,
"approved": true,
"disabled": false,
"silenced": false,
"suspended": false,
"account": {
"id": "01F8MH5NBDF2MV7CTC4Q5128HF",
"username": "1happyturtle",
"acct": "1happyturtle",
"display_name": "happy little turtle :3",
"locked": true,
"bot": false,
"created_at": "2022-06-04T13:12:00.000Z",
"note": "\u003cp\u003ei post about things that concern me\u003c/p\u003e",
"url": "http://localhost:8080/@1happyturtle",
"avatar": "",
"avatar_static": "",
"header": "http://localhost:8080/assets/default_header.png",
"header_static": "http://localhost:8080/assets/default_header.png",
"followers_count": 1,
"following_count": 1,
"statuses_count": 7,
"last_status_at": "2021-10-20T10:40:37.000Z",
"emojis": [],
"fields": [],
"role": "user"
},
"created_by_application_id": "01F8MGY43H3N2C8EWPR2FPYEXG"
},
"target_account": {
"id": "01F8MH5ZK5VRH73AKHQM6Y9VNX",
"username": "foss_satan",
"domain": "fossbros-anonymous.io",
"created_at": "2021-09-26T10:52:36.000Z",
"email": "",
"ip": null,
"ips": [],
"locale": "",
"invite_request": null,
"role": "user",
"confirmed": false,
"approved": false,
"disabled": false,
"silenced": false,
"suspended": false,
"account": {
"id": "01F8MH5ZK5VRH73AKHQM6Y9VNX",
"username": "foss_satan",
"acct": "foss_satan@fossbros-anonymous.io",
"display_name": "big gerald",
"locked": false,
"bot": false,
"created_at": "2021-09-26T10:52:36.000Z",
"note": "i post about like, i dunno, stuff, or whatever!!!!",
"url": "http://fossbros-anonymous.io/@foss_satan",
"avatar": "",
"avatar_static": "",
"header": "http://localhost:8080/assets/default_header.png",
"header_static": "http://localhost:8080/assets/default_header.png",
"followers_count": 0,
"following_count": 0,
"statuses_count": 1,
"last_status_at": "2021-09-20T10:40:37.000Z",
"emojis": [],
"fields": []
}
},
"assigned_account": null,
"action_taken_by_account": null,
"statuses": [
{
"id": "01FVW7JHQFSFK166WWKR8CBA6M",
"created_at": "2021-09-20T10:40:37.000Z",
"in_reply_to_id": null,
"in_reply_to_account_id": null,
"sensitive": false,
"spoiler_text": "",
"visibility": "unlisted",
"language": "en",
"uri": "http://fossbros-anonymous.io/users/foss_satan/statuses/01FVW7JHQFSFK166WWKR8CBA6M",
"url": "http://fossbros-anonymous.io/@foss_satan/statuses/01FVW7JHQFSFK166WWKR8CBA6M",
"replies_count": 0,
"reblogs_count": 0,
"favourites_count": 0,
"favourited": false,
"reblogged": false,
"muted": false,
"bookmarked": false,
"pinned": false,
"content": "dark souls status bot: \"thoughts of dog\"",
"reblog": null,
"account": {
"id": "01F8MH5ZK5VRH73AKHQM6Y9VNX",
"username": "foss_satan",
"acct": "foss_satan@fossbros-anonymous.io",
"display_name": "big gerald",
"locked": false,
"bot": false,
"created_at": "2021-09-26T10:52:36.000Z",
"note": "i post about like, i dunno, stuff, or whatever!!!!",
"url": "http://fossbros-anonymous.io/@foss_satan",
"avatar": "",
"avatar_static": "",
"header": "http://localhost:8080/assets/default_header.png",
"header_static": "http://localhost:8080/assets/default_header.png",
"followers_count": 0,
"following_count": 0,
"statuses_count": 1,
"last_status_at": "2021-09-20T10:40:37.000Z",
"emojis": [],
"fields": []
},
"media_attachments": [
{
"id": "01FVW7RXPQ8YJHTEXYPE7Q8ZY0",
"type": "image",
"url": "http://localhost:8080/fileserver/01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/original/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpg",
"text_url": "http://localhost:8080/fileserver/01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/original/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpg",
"preview_url": "http://localhost:8080/fileserver/01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/small/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpg",
"remote_url": "http://fossbros-anonymous.io/attachments/original/13bbc3f8-2b5e-46ea-9531-40b4974d9912.jpg",
"preview_remote_url": "http://fossbros-anonymous.io/attachments/small/a499f55b-2d1e-4acd-98d2-1ac2ba6d79b9.jpg",
"meta": {
"original": {
"width": 472,
"height": 291,
"size": "472x291",
"aspect": 1.6219932
},
"small": {
"width": 472,
"height": 291,
"size": "472x291",
"aspect": 1.6219932
},
"focus": {
"x": 0,
"y": 0
}
},
"description": "tweet from thoughts of dog: i drank. all the water. in my bowl. earlier. but just now. i returned. to the same bowl. and it was. full again.. the bowl. is haunted",
"blurhash": "LARysgM_IU_3~pD%M_Rj_39FIAt6"
}
],
"mentions": [],
"tags": [],
"emojis": [],
"card": null,
"poll": null
}
],
"rule_ids": [],
"action_taken_comment": null
}`, string(b))
}
func TestInternalToFrontendTestSuite(t *testing.T) {
suite.Run(t, new(InternalToFrontendTestSuite))
}

View File

@ -1947,7 +1947,7 @@ func NewTestReports() map[string]*gtsmodel.Report {
Forwarded: TrueBool(),
ActionTaken: "user was warned not to be a turtle anymore",
ActionTakenAt: TimeMustParse("2022-05-15T17:01:56+02:00"),
ActionTakenByAccountID: "01AY6P665V14JJR0AFVRT7311Y",
ActionTakenByAccountID: "01F8MH17FWEB39HZJ76B6VXSKF",
},
}
}