diff options
author | 2023-01-25 11:12:17 +0100 | |
---|---|---|
committer | 2023-01-25 11:12:17 +0100 | |
commit | faeb7ded3b5d595910f424fd9cf9c6fe5935e648 (patch) | |
tree | 5c50b950277ab985e73bfaf027b53ee82f4917a6 /internal | |
parent | [chore] Settings refactor fix4 (#1383) (diff) | |
download | gotosocial-faeb7ded3b5d595910f424fd9cf9c6fe5935e648.tar.xz |
[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
Diffstat (limited to 'internal')
-rw-r--r-- | internal/api/client/admin/admin.go | 29 | ||||
-rw-r--r-- | internal/api/client/admin/admin_test.go | 2 | ||||
-rw-r--r-- | internal/api/client/admin/reportget.go | 103 | ||||
-rw-r--r-- | internal/api/client/admin/reportresolve.go | 125 | ||||
-rw-r--r-- | internal/api/client/admin/reportresolve_test.go | 168 | ||||
-rw-r--r-- | internal/api/client/admin/reportsget.go | 184 | ||||
-rw-r--r-- | internal/api/client/admin/reportsget_test.go | 905 | ||||
-rw-r--r-- | internal/api/model/admin.go | 90 | ||||
-rw-r--r-- | internal/api/model/report.go | 2 | ||||
-rw-r--r-- | internal/processing/admin.go | 12 | ||||
-rw-r--r-- | internal/processing/admin/admin.go | 3 | ||||
-rw-r--r-- | internal/processing/admin/getreport.go | 45 | ||||
-rw-r--r-- | internal/processing/admin/getreports.go | 92 | ||||
-rw-r--r-- | internal/processing/admin/resolvereport.go | 64 | ||||
-rw-r--r-- | internal/processing/processor.go | 7 | ||||
-rw-r--r-- | internal/typeutils/converter.go | 2 | ||||
-rw-r--r-- | internal/typeutils/internaltofrontend.go | 166 | ||||
-rw-r--r-- | internal/typeutils/internaltofrontend_test.go | 366 |
18 files changed, 2354 insertions, 11 deletions
diff --git a/internal/api/client/admin/admin.go b/internal/api/client/admin/admin.go index 31670bac8..f7e54d271 100644 --- a/internal/api/client/admin/admin.go +++ b/internal/api/client/admin/admin.go @@ -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) } diff --git a/internal/api/client/admin/admin_test.go b/internal/api/client/admin/admin_test.go index a7c402c49..000ca1927 100644 --- a/internal/api/client/admin/admin_test.go +++ b/internal/api/client/admin/admin_test.go @@ -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() { diff --git a/internal/api/client/admin/reportget.go b/internal/api/client/admin/reportget.go new file mode 100644 index 000000000..5f2fbbb91 --- /dev/null +++ b/internal/api/client/admin/reportget.go @@ -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) +} diff --git a/internal/api/client/admin/reportresolve.go b/internal/api/client/admin/reportresolve.go new file mode 100644 index 000000000..e34bd6814 --- /dev/null +++ b/internal/api/client/admin/reportresolve.go @@ -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) +} diff --git a/internal/api/client/admin/reportresolve_test.go b/internal/api/client/admin/reportresolve_test.go new file mode 100644 index 000000000..7b340183e --- /dev/null +++ b/internal/api/client/admin/reportresolve_test.go @@ -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{}) +} diff --git a/internal/api/client/admin/reportsget.go b/internal/api/client/admin/reportsget.go new file mode 100644 index 000000000..51108e461 --- /dev/null +++ b/internal/api/client/admin/reportsget.go @@ -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) +} diff --git a/internal/api/client/admin/reportsget_test.go b/internal/api/client/admin/reportsget_test.go new file mode 100644 index 000000000..9bcc3dcd0 --- /dev/null +++ b/internal/api/client/admin/reportsget_test.go @@ -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{}) +} diff --git a/internal/api/model/admin.go b/internal/api/model/admin.go index edef0d52b..df688694d 100644 --- a/internal/api/model/admin.go +++ b/internal/api/model/admin.go @@ -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. diff --git a/internal/api/model/report.go b/internal/api/model/report.go index a994bdf02..73df79538 100644 --- a/internal/api/model/report.go +++ b/internal/api/model/report.go @@ -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"` diff --git a/internal/processing/admin.go b/internal/processing/admin.go index 1b4744967..b85185290 100644 --- a/internal/processing/admin.go +++ b/internal/processing/admin.go @@ -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) +} diff --git a/internal/processing/admin/admin.go b/internal/processing/admin/admin.go index 861a1ee4a..b08b589bb 100644 --- a/internal/processing/admin/admin.go +++ b/internal/processing/admin/admin.go @@ -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 { diff --git a/internal/processing/admin/getreport.go b/internal/processing/admin/getreport.go new file mode 100644 index 000000000..6c2f93935 --- /dev/null +++ b/internal/processing/admin/getreport.go @@ -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 +} diff --git a/internal/processing/admin/getreports.go b/internal/processing/admin/getreports.go new file mode 100644 index 000000000..fbc4b45b2 --- /dev/null +++ b/internal/processing/admin/getreports.go @@ -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, + }) +} diff --git a/internal/processing/admin/resolvereport.go b/internal/processing/admin/resolvereport.go new file mode 100644 index 000000000..5c1dca1b0 --- /dev/null +++ b/internal/processing/admin/resolvereport.go @@ -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 +} diff --git a/internal/processing/processor.go b/internal/processing/processor.go index 692523042..46634aaaa 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -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) diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go index be05a8a48..c7fd31470 100644 --- a/internal/typeutils/converter.go +++ b/internal/typeutils/converter.go @@ -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 diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index dbd1a3822..2483fc5ba 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -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 diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go index 7fd08ee05..0c888a521 100644 --- a/internal/typeutils/internaltofrontend_test.go +++ b/internal/typeutils/internaltofrontend_test.go @@ -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)) } |