diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index b3a75d177..9182f48c8 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -1510,6 +1510,83 @@ definitions: type: object x-go-name: PollOptions x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model + report: + properties: + 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_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: ActionComment + 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. + Will be empty if no rule IDs were submitted. + example: + - 1 + - 2 + items: + format: int64 + type: integer + type: array + x-go-name: RuleIDs + status_ids: + description: |- + Array of IDs of statuses that were submitted along with this report. + Will be empty if no status IDs were submitted. + example: + - 01GPBN5YDY6JKBWE44H7YQBDCQ + - 01GPBN65PDWSBPWVDD0SQCFFY3 + items: + type: string + type: array + x-go-name: StatusIDs + target_account: + $ref: '#/definitions/account' + title: Report models a moderation report submitted to the instance, either via the client API or via the federated API. + type: object + x-go-name: Report + x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model searchResult: properties: accounts: @@ -3897,6 +3974,185 @@ paths: summary: Clear/delete all notifications for currently authorized user. tags: - notifications + /api/v1/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: + + ``` + ; rel="next", ; rel="prev" + ```` + operationId: reports + 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 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/report' + 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: + - read:reports + summary: See reports created by the requesting account. + tags: + - reports + post: + consumes: + - application/json + - application/xml + - application/x-www-form-urlencoded + operationId: reportCreate + parameters: + - description: ID of the account to report. + example: 01GPE75FXSH2EGFBF85NXPH3KP + in: formData + name: account_id + required: true + type: string + x-go-name: AccountID + - description: IDs of statuses to attach to the report to provide additional context. + example: + - 01GPE76N4SBVRZ8K24TW51ZZQ4 + - 01GPE76WN9JZE62EPT3Q9FRRD4 + in: formData + items: + type: string + name: status_ids + type: array + x-go-name: StatusIDs + - description: The reason for the report. Default maximum of 1000 characters. + example: Anti-Blackness, transphobia. + in: formData + name: comment + type: string + x-go-name: Comment + - default: false + description: If the account is remote, should the report be forwarded to the remote admin? + example: true + in: formData + name: forward + type: boolean + x-go-name: Forward + - default: other + description: |- + Specify if the report is due to spam, violation of enumerated instance rules, or some other reason. + Currently only 'other' is supported. + example: other + in: formData + name: category + type: string + x-go-name: Category + - description: |- + IDs of rules on this instance which have been broken according to the reporter. + This is currently not supported, provided only for API compatibility. + example: + - 1 + - 2 + - 3 + in: formData + items: + format: int64 + type: integer + name: rule_ids + type: array + x-go-name: RuleIDs + produces: + - application/json + responses: + "200": + description: The created report. + schema: + $ref: '#/definitions/report' + "400": + description: bad request + "401": + description: unauthorized + "404": + description: not found + "406": + description: not acceptable + "500": + description: internal server error + security: + - OAuth2 Bearer: + - write:reports + summary: Create a new user report with the given parameters. + tags: + - reports + /api/v1/reports/{id}: + get: + operationId: reportGet + parameters: + - description: ID of the report + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: The requested report. + schema: + $ref: '#/definitions/report' + "400": + description: bad request + "401": + description: unauthorized + "404": + description: not found + "406": + description: not acceptable + "500": + description: internal server error + security: + - OAuth2 Bearer: + - read:reports + summary: Get one report with the given id. + tags: + - reports /api/v1/search: get: description: If statuses are in the result, they will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer). diff --git a/internal/api/client.go b/internal/api/client.go index b365441ef..0ee46bba9 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -35,6 +35,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/api/client/lists" "github.com/superseriousbusiness/gotosocial/internal/api/client/media" "github.com/superseriousbusiness/gotosocial/internal/api/client/notifications" + "github.com/superseriousbusiness/gotosocial/internal/api/client/reports" "github.com/superseriousbusiness/gotosocial/internal/api/client/search" "github.com/superseriousbusiness/gotosocial/internal/api/client/statuses" "github.com/superseriousbusiness/gotosocial/internal/api/client/streaming" @@ -63,6 +64,7 @@ type Client struct { lists *lists.Module // api/v1/lists media *media.Module // api/v1/media, api/v2/media notifications *notifications.Module // api/v1/notifications + reports *reports.Module // api/v1/reports search *search.Module // api/v1/search, api/v2/search statuses *statuses.Module // api/v1/statuses streaming *streaming.Module // api/v1/streaming @@ -97,6 +99,7 @@ func (c *Client) Route(r router.Router, m ...gin.HandlerFunc) { c.lists.Route(h) c.media.Route(h) c.notifications.Route(h) + c.reports.Route(h) c.search.Route(h) c.statuses.Route(h) c.streaming.Route(h) @@ -122,6 +125,7 @@ func NewClient(db db.DB, p processing.Processor) *Client { lists: lists.New(p), media: media.New(p), notifications: notifications.New(p), + reports: reports.New(p), search: search.New(p), statuses: statuses.New(p), streaming: streaming.New(p, time.Second*30, 4096), diff --git a/internal/api/client/reports/reportcreate.go b/internal/api/client/reports/reportcreate.go new file mode 100644 index 000000000..c2548985a --- /dev/null +++ b/internal/api/client/reports/reportcreate.go @@ -0,0 +1,112 @@ +/* + 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 . +*/ + +package reports + +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" + "github.com/superseriousbusiness/gotosocial/internal/regexes" +) + +// ReportPOSTHandler swagger:operation POST /api/v1/reports reportCreate +// +// Create a new user report with the given parameters. +// +// --- +// tags: +// - reports +// +// consumes: +// - application/json +// - application/xml +// - application/x-www-form-urlencoded +// +// produces: +// - application/json +// +// security: +// - OAuth2 Bearer: +// - write:reports +// +// responses: +// '200': +// description: The created report. +// schema: +// "$ref": "#/definitions/report" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) ReportPOSTHandler(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 _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + form := &apimodel.ReportCreateRequest{} + if err := c.ShouldBind(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + if form.AccountID == "" { + err = errors.New("account_id must be set") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + if !regexes.ULID.MatchString(form.AccountID) { + err = errors.New("account_id was not valid") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + if length := len([]rune(form.Comment)); length > 1000 { + err = fmt.Errorf("comment length must be no more than 1000 chars, provided comment was %d chars", length) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + apiReport, errWithCode := m.processor.ReportCreate(c.Request.Context(), authed, form) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, apiReport) +} diff --git a/internal/api/client/reports/reportcreate_test.go b/internal/api/client/reports/reportcreate_test.go new file mode 100644 index 000000000..32fa34695 --- /dev/null +++ b/internal/api/client/reports/reportcreate_test.go @@ -0,0 +1,201 @@ +/* + 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 . +*/ + +package reports_test + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/reports" + 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/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type ReportCreateTestSuite struct { + ReportsStandardTestSuite +} + +func (suite *ReportCreateTestSuite) createReport(expectedHTTPStatus int, expectedBody string, form *apimodel.ReportCreateRequest) (*apimodel.Report, error) { + // instantiate recorder + test context + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + + // create the request + ctx.Request = httptest.NewRequest(http.MethodPost, config.GetProtocol()+"://"+config.GetHost()+"/api/"+reports.BasePath, nil) + ctx.Request.Header.Set("accept", "application/json") + ruleIDs := make([]string, 0, len(form.RuleIDs)) + for _, r := range form.RuleIDs { + ruleIDs = append(ruleIDs, strconv.Itoa(r)) + } + ctx.Request.Form = url.Values{ + "account_id": {form.AccountID}, + "status_ids[]": form.StatusIDs, + "comment": {form.Comment}, + "forward": {strconv.FormatBool(form.Forward)}, + "category": {form.Category}, + "rule_ids[]": ruleIDs, + } + + // trigger the handler + suite.reportsModule.ReportPOSTHandler(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{} + + // check code + body + 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.Report{} + if err := json.Unmarshal(b, resp); err != nil { + return nil, err + } + + return resp, nil +} + +func (suite *ReportCreateTestSuite) ReportOK(form *apimodel.ReportCreateRequest, report *apimodel.Report) { + suite.Equal(form.AccountID, report.TargetAccount.ID) + suite.Equal(form.StatusIDs, report.StatusIDs) + suite.Equal(form.Comment, report.Comment) + suite.Equal(form.Forward, report.Forwarded) +} + +func (suite *ReportCreateTestSuite) TestCreateReport1() { + targetAccount := suite.testAccounts["remote_account_1"] + + form := &apimodel.ReportCreateRequest{ + AccountID: targetAccount.ID, + StatusIDs: []string{}, + Comment: "", + Forward: false, + } + + report, err := suite.createReport(http.StatusOK, "", form) + suite.NoError(err) + suite.NotEmpty(report) + suite.ReportOK(form, report) +} + +func (suite *ReportCreateTestSuite) TestCreateReport2() { + targetAccount := suite.testAccounts["remote_account_1"] + targetStatus := suite.testStatuses["remote_account_1_status_1"] + + form := &apimodel.ReportCreateRequest{ + AccountID: targetAccount.ID, + StatusIDs: []string{targetStatus.ID}, + Comment: "noooo don't post your so sexy aha", + Forward: true, + } + + report, err := suite.createReport(http.StatusOK, "", form) + suite.NoError(err) + suite.NotEmpty(report) + suite.ReportOK(form, report) +} + +func (suite *ReportCreateTestSuite) TestCreateReport3() { + form := &apimodel.ReportCreateRequest{} + + report, err := suite.createReport(http.StatusBadRequest, `{"error":"Bad Request: account_id must be set"}`, form) + suite.NoError(err) + suite.Nil(report) +} + +func (suite *ReportCreateTestSuite) TestCreateReport4() { + form := &apimodel.ReportCreateRequest{ + AccountID: "boobs", + StatusIDs: []string{}, + Comment: "", + Forward: true, + } + + report, err := suite.createReport(http.StatusBadRequest, `{"error":"Bad Request: account_id was not valid"}`, form) + suite.NoError(err) + suite.Nil(report) +} + +func (suite *ReportCreateTestSuite) TestCreateReport5() { + testAccount := suite.testAccounts["local_account_1"] + form := &apimodel.ReportCreateRequest{ + AccountID: testAccount.ID, + } + + report, err := suite.createReport(http.StatusBadRequest, `{"error":"Bad Request: cannot report your own account"}`, form) + suite.NoError(err) + suite.Nil(report) +} + +func (suite *ReportCreateTestSuite) TestCreateReport6() { + targetAccount := suite.testAccounts["remote_account_1"] + + form := &apimodel.ReportCreateRequest{ + AccountID: targetAccount.ID, + Comment: "netus et malesuada fames ac turpis egestas sed tempus urna et pharetra pharetra massa massa ultricies mi quis hendrerit dolor magna eget est lorem ipsum dolor sit amet consectetur adipiscing elit pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas integer eget aliquet nibh praesent tristique magna sit amet purus gravida quis blandit turpis cursus in hac habitasse platea dictumst quisque sagittis purus sit amet volutpat consequat mauris nunc congue nisi vitae suscipit tellus mauris a diam maecenas sed enim ut sem viverra aliquet eget sit amet tellus cras adipiscing enim eu turpis egestas pretium aenean pharetra magna ac placerat vestibulum lectus mauris ultrices eros in cursus turpis massa tincidunt dui ut ornare lectus sit amet est placerat in egestas erat imperdiet sed euismod nisi porta lorem mollis aliquam ut porttitor leo a diam sollicitudin tempor id eu nisl nunc mi ipsum faucibus vitae aliquet nec ullamcorper sit amet risus nullam eget felis eget nunc lobortis mattis aliquam faucibus purus in massa tempor nec feugiat nisl pretium fusce id velit ut tortor pretium viverra suspendisse potenti nullam ac tortor vitae purus faucibus ornare suspendisse sed nisi lacus sed viverra tellus in hac habitasse platea dictumst vestibulum rhoncus est pellentesque elit ullamcorper dignissim cras tincidunt lobortis feugiat vivamus at augue eget arcu dictum varius duis at consectetur lorem donec massa sapien faucibus et molestie ac feugiat sed lectus vestibulum mattis ullamcorper velit sed ullamcorper morbi tincidunt ornare massa eget ", + } + + report, err := suite.createReport(http.StatusBadRequest, `{"error":"Bad Request: comment length must be no more than 1000 chars, provided comment was 1588 chars"}`, form) + suite.NoError(err) + suite.Nil(report) +} + +func (suite *ReportCreateTestSuite) TestCreateReport7() { + form := &apimodel.ReportCreateRequest{ + AccountID: "01GPGH5ENXWE5K65YNNXYWAJA4", + } + + report, err := suite.createReport(http.StatusBadRequest, `{"error":"Bad Request: account with ID 01GPGH5ENXWE5K65YNNXYWAJA4 does not exist"}`, form) + suite.NoError(err) + suite.Nil(report) +} + +func TestReportCreateTestSuite(t *testing.T) { + suite.Run(t, &ReportCreateTestSuite{}) +} diff --git a/internal/api/client/reports/reportget.go b/internal/api/client/reports/reportget.go new file mode 100644 index 000000000..9df4df05f --- /dev/null +++ b/internal/api/client/reports/reportget.go @@ -0,0 +1,95 @@ +/* + 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 . +*/ + +package reports + +import ( + "errors" + "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/reports/{id} reportGet +// +// Get one report with the given id. +// +// --- +// tags: +// - reports +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: ID of the report +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - read:reports +// +// responses: +// '200': +// description: The requested report. +// schema: +// "$ref": "#/definitions/report" +// '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 _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + targetReportID := c.Param(IDKey) + if targetReportID == "" { + err := errors.New("no report id specified") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + report, errWithCode := m.processor.ReportGet(c.Request.Context(), authed, targetReportID) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, report) +} diff --git a/internal/api/client/reports/reportget_test.go b/internal/api/client/reports/reportget_test.go new file mode 100644 index 000000000..bfde5c9fd --- /dev/null +++ b/internal/api/client/reports/reportget_test.go @@ -0,0 +1,159 @@ +/* + 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 . +*/ + +package reports_test + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/reports" + 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/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type ReportGetTestSuite struct { + ReportsStandardTestSuite +} + +func (suite *ReportGetTestSuite) getReport(expectedHTTPStatus int, expectedBody string, reportID string) (*apimodel.Report, error) { + // instantiate recorder + test context + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_2"]) + ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_2"])) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_2"]) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_2"]) + + // create the request + ctx.Request = httptest.NewRequest(http.MethodGet, config.GetProtocol()+"://"+config.GetHost()+"/api/"+reports.BasePath+"/"+reportID, nil) + ctx.Request.Header.Set("accept", "application/json") + ctx.AddParam("id", reportID) + + // trigger the handler + suite.reportsModule.ReportGETHandler(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{} + + // check code + body + 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.Report{} + if err := json.Unmarshal(b, resp); err != nil { + return nil, err + } + + return resp, nil +} + +func (suite *ReportGetTestSuite) TestGetReport1() { + targetReport := suite.testReports["local_account_2_report_remote_account_1"] + + report, err := suite.getReport(http.StatusOK, "", targetReport.ID) + suite.NoError(err) + suite.NotNil(report) + + b, err := json.MarshalIndent(&report, "", " ") + suite.NoError(err) + + suite.Equal(`{ + "id": "01GP3AWY4CRDVRNZKW0TEAMB5R", + "created_at": "2022-05-14T10:20:03.000Z", + "action_taken": false, + "action_taken_at": null, + "action_taken_comment": null, + "category": "other", + "comment": "dark souls sucks, please yeet this nerd", + "forwarded": true, + "status_ids": [ + "01FVW7JHQFSFK166WWKR8CBA6M" + ], + "rule_ids": [], + "target_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": [] + } +}`, string(b)) +} + +func (suite *ReportGetTestSuite) TestGetReport2() { + targetReport := suite.testReports["remote_account_1_report_local_account_2"] + report, err := suite.getReport(http.StatusNotFound, `{"error":"Not Found"}`, targetReport.ID) + suite.NoError(err) + suite.Nil(report) +} + +func (suite *ReportGetTestSuite) TestGetReport3() { + report, err := suite.getReport(http.StatusBadRequest, `{"error":"Bad Request: no report id specified"}`, "") + suite.NoError(err) + suite.Nil(report) +} + +func (suite *ReportGetTestSuite) TestGetReport4() { + report, err := suite.getReport(http.StatusNotFound, `{"error":"Not Found"}`, "01GPJWHQS1BG0SF0WZ1SABC4RZ") + suite.NoError(err) + suite.Nil(report) +} + +func TestReportGetTestSuite(t *testing.T) { + suite.Run(t, &ReportGetTestSuite{}) +} diff --git a/internal/api/client/reports/reports.go b/internal/api/client/reports/reports.go new file mode 100644 index 000000000..41b61582c --- /dev/null +++ b/internal/api/client/reports/reports.go @@ -0,0 +1,54 @@ +/* + 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 . +*/ + +package reports + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/processing" +) + +const ( + BasePath = "/v1/reports" + IDKey = "id" + ResolvedKey = "resolved" + TargetAccountIDKey = "target_account_id" + MaxIDKey = "max_id" + SinceIDKey = "since_id" + MinIDKey = "min_id" + LimitKey = "limit" + BasePathWithID = BasePath + "/:" + IDKey +) + +type Module struct { + processor processing.Processor +} + +func New(processor processing.Processor) *Module { + return &Module{ + processor: processor, + } +} + +func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + attachHandler(http.MethodGet, BasePath, m.ReportsGETHandler) + attachHandler(http.MethodPost, BasePath, m.ReportPOSTHandler) + attachHandler(http.MethodGet, BasePathWithID, m.ReportGETHandler) +} diff --git a/internal/api/client/reports/reports_test.go b/internal/api/client/reports/reports_test.go new file mode 100644 index 000000000..f5f5bf91e --- /dev/null +++ b/internal/api/client/reports/reports_test.go @@ -0,0 +1,93 @@ +/* + 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 . +*/ + +package reports_test + +import ( + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/reports" + "github.com/superseriousbusiness/gotosocial/internal/concurrency" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/email" + "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/messages" + "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type ReportsStandardTestSuite struct { + suite.Suite + db db.DB + storage *storage.Driver + mediaManager media.Manager + federator federation.Federator + processor processing.Processor + emailSender email.Sender + sentEmails map[string]string + + // standard suite models + testTokens map[string]*gtsmodel.Token + testClients map[string]*gtsmodel.Client + testApplications map[string]*gtsmodel.Application + testUsers map[string]*gtsmodel.User + testAccounts map[string]*gtsmodel.Account + testStatuses map[string]*gtsmodel.Status + testReports map[string]*gtsmodel.Report + + // module being tested + reportsModule *reports.Module +} + +func (suite *ReportsStandardTestSuite) SetupSuite() { + suite.testTokens = testrig.NewTestTokens() + suite.testClients = testrig.NewTestClients() + suite.testApplications = testrig.NewTestApplications() + suite.testUsers = testrig.NewTestUsers() + suite.testAccounts = testrig.NewTestAccounts() + suite.testStatuses = testrig.NewTestStatuses() + suite.testReports = testrig.NewTestReports() +} + +func (suite *ReportsStandardTestSuite) SetupTest() { + testrig.InitTestConfig() + testrig.InitTestLog() + + fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) + clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) + + suite.db = testrig.NewTestDB() + suite.storage = testrig.NewInMemoryStorage() + suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage) + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker), suite.storage, suite.mediaManager, fedWorker) + suite.sentEmails = make(map[string]string) + suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails) + suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager, clientWorker, fedWorker) + suite.reportsModule = reports.New(suite.processor) + testrig.StandardDBSetup(suite.db, nil) + testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") + + suite.NoError(suite.processor.Start()) +} + +func (suite *ReportsStandardTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} diff --git a/internal/api/client/reports/reportsget.go b/internal/api/client/reports/reportsget.go new file mode 100644 index 000000000..be2e01222 --- /dev/null +++ b/internal/api/client/reports/reportsget.go @@ -0,0 +1,173 @@ +/* + 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 . +*/ + +package reports + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// ReportsGETHandler swagger:operation GET /api/v1/reports reports +// +// See reports created by the requesting account. +// +// 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: +// +// ``` +// ; rel="next", ; rel="prev" +// ```` +// +// --- +// tags: +// - reports +// +// 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: 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: +// - read:reports +// +// responses: +// '200': +// name: reports +// description: Array of reports. +// schema: +// type: array +// items: +// "$ref": "#/definitions/report" +// '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 _, 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.ReportsGet(c.Request.Context(), authed, resolved, 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/reports/reportsget_test.go b/internal/api/client/reports/reportsget_test.go new file mode 100644 index 000000000..e970d9c8f --- /dev/null +++ b/internal/api/client/reports/reportsget_test.go @@ -0,0 +1,376 @@ +/* + 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 . +*/ + +package reports_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/reports" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type ReportsGetTestSuite struct { + ReportsStandardTestSuite +} + +func (suite *ReportsGetTestSuite) getReports( + account *gtsmodel.Account, + token *gtsmodel.Token, + user *gtsmodel.User, + expectedHTTPStatus int, + resolved *bool, + targetAccountID string, + maxID string, + sinceID string, + minID string, + limit int, +) ([]*apimodel.Report, 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 := reports.BasePath + "?" + reports.LimitKey + "=" + strconv.Itoa(limit) + if resolved != nil { + requestPath = requestPath + "&" + reports.ResolvedKey + "=" + strconv.FormatBool(*resolved) + } + if targetAccountID != "" { + requestPath = requestPath + "&" + reports.TargetAccountIDKey + "=" + targetAccountID + } + if maxID != "" { + requestPath = requestPath + "&" + reports.MaxIDKey + "=" + maxID + } + if sinceID != "" { + requestPath = requestPath + "&" + reports.SinceIDKey + "=" + sinceID + } + if minID != "" { + requestPath = requestPath + "&" + reports.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.reportsModule.ReportsGETHandler(ctx) + + // read the response + result := recorder.Result() + defer result.Body.Close() + + if resultCode := recorder.Code; expectedHTTPStatus != resultCode { + return nil, "", fmt.Errorf("expected %d got %d", expectedHTTPStatus, resultCode) + } + + b, err := ioutil.ReadAll(result.Body) + if err != nil { + return nil, "", err + } + + resp := []*apimodel.Report{} + if err := json.Unmarshal(b, &resp); err != nil { + return nil, "", err + } + + return resp, result.Header.Get("Link"), nil +} + +func (suite *ReportsGetTestSuite) TestGetReports() { + testAccount := suite.testAccounts["local_account_2"] + testToken := suite.testTokens["local_account_2"] + testUser := suite.testUsers["local_account_2"] + + 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": "01GP3AWY4CRDVRNZKW0TEAMB5R", + "created_at": "2022-05-14T10:20:03.000Z", + "action_taken": false, + "action_taken_at": null, + "action_taken_comment": null, + "category": "other", + "comment": "dark souls sucks, please yeet this nerd", + "forwarded": true, + "status_ids": [ + "01FVW7JHQFSFK166WWKR8CBA6M" + ], + "rule_ids": [], + "target_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": [] + } + } +]`, string(b)) + + suite.Equal(`; rel="next", ; rel="prev"`, link) +} + +func (suite *ReportsGetTestSuite) TestGetReports2() { + testAccount := suite.testAccounts["local_account_2"] + testToken := suite.testTokens["local_account_2"] + testUser := suite.testUsers["local_account_2"] + + reports, link, err := suite.getReports(testAccount, testToken, testUser, http.StatusOK, nil, "", "01GP3AWY4CRDVRNZKW0TEAMB5R", "", "", 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) TestGetReports3() { + testAccount := suite.testAccounts["local_account_1"] + testToken := suite.testTokens["local_account_1"] + testUser := suite.testUsers["local_account_1"] + + reports, link, err := suite.getReports(testAccount, testToken, testUser, http.StatusOK, nil, "", "", "", "", 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) TestGetReports4() { + testAccount := suite.testAccounts["local_account_2"] + testToken := suite.testTokens["local_account_2"] + testUser := suite.testUsers["local_account_2"] + resolved := testrig.FalseBool() + + reports, link, err := suite.getReports(testAccount, testToken, testUser, http.StatusOK, resolved, "", "", "", "", 20) + suite.NoError(err) + suite.NotEmpty(reports) + + b, err := json.MarshalIndent(&reports, "", " ") + suite.NoError(err) + + suite.Equal(`[ + { + "id": "01GP3AWY4CRDVRNZKW0TEAMB5R", + "created_at": "2022-05-14T10:20:03.000Z", + "action_taken": false, + "action_taken_at": null, + "action_taken_comment": null, + "category": "other", + "comment": "dark souls sucks, please yeet this nerd", + "forwarded": true, + "status_ids": [ + "01FVW7JHQFSFK166WWKR8CBA6M" + ], + "rule_ids": [], + "target_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": [] + } + } +]`, string(b)) + + suite.Equal(`; rel="next", ; rel="prev"`, link) +} + +func (suite *ReportsGetTestSuite) TestGetReports5() { + testAccount := suite.testAccounts["local_account_1"] + testToken := suite.testTokens["local_account_1"] + testUser := suite.testUsers["local_account_1"] + resolved := testrig.TrueBool() + + reports, link, err := suite.getReports(testAccount, testToken, testUser, http.StatusOK, resolved, "", "", "", "", 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) TestGetReports6() { + testAccount := suite.testAccounts["local_account_2"] + testToken := suite.testTokens["local_account_2"] + testUser := suite.testUsers["local_account_2"] + + reports, link, err := suite.getReports(testAccount, testToken, testUser, http.StatusOK, nil, "01F8MH5ZK5VRH73AKHQM6Y9VNX", "", "", "", 20) + suite.NoError(err) + suite.NotEmpty(reports) + + b, err := json.MarshalIndent(&reports, "", " ") + suite.NoError(err) + + suite.Equal(`[ + { + "id": "01GP3AWY4CRDVRNZKW0TEAMB5R", + "created_at": "2022-05-14T10:20:03.000Z", + "action_taken": false, + "action_taken_at": null, + "action_taken_comment": null, + "category": "other", + "comment": "dark souls sucks, please yeet this nerd", + "forwarded": true, + "status_ids": [ + "01FVW7JHQFSFK166WWKR8CBA6M" + ], + "rule_ids": [], + "target_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": [] + } + } +]`, string(b)) + + suite.Equal(`; rel="next", ; rel="prev"`, link) +} + +func (suite *ReportsGetTestSuite) TestGetReports7() { + testAccount := suite.testAccounts["local_account_2"] + testToken := suite.testTokens["local_account_2"] + testUser := suite.testUsers["local_account_2"] + resolved := testrig.FalseBool() + + reports, link, err := suite.getReports(testAccount, testToken, testUser, http.StatusOK, resolved, "01F8MH5ZK5VRH73AKHQM6Y9VNX", "", "", "", 20) + suite.NoError(err) + suite.NotEmpty(reports) + + b, err := json.MarshalIndent(&reports, "", " ") + suite.NoError(err) + + suite.Equal(`[ + { + "id": "01GP3AWY4CRDVRNZKW0TEAMB5R", + "created_at": "2022-05-14T10:20:03.000Z", + "action_taken": false, + "action_taken_at": null, + "action_taken_comment": null, + "category": "other", + "comment": "dark souls sucks, please yeet this nerd", + "forwarded": true, + "status_ids": [ + "01FVW7JHQFSFK166WWKR8CBA6M" + ], + "rule_ids": [], + "target_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": [] + } + } +]`, string(b)) + + suite.Equal(`; rel="next", ; rel="prev"`, link) +} + +func TestReportsGetTestSuite(t *testing.T) { + suite.Run(t, &ReportsGetTestSuite{}) +} diff --git a/internal/api/model/admin.go b/internal/api/model/admin.go index 436dce390..edef0d52b 100644 --- a/internal/api/model/admin.go +++ b/internal/api/model/admin.go @@ -58,26 +58,7 @@ type AdminAccountInfo struct { // AdminReportInfo models the admin view of a report. type AdminReportInfo struct { - // The ID of the report in the database. - ID string `json:"id"` - // The action taken to resolve this report. - ActionTaken string `json:"action_taken"` - // An optional reason for reporting. - Comment string `json:"comment"` - // The time the report was filed. (ISO 8601 Datetime) - CreatedAt string `json:"created_at"` - // The time of last action on this report. (ISO 8601 Datetime) - UpdatedAt string `json:"updated_at"` - // The account which filed the report. - Account *Account `json:"account"` - // The account being reported. - TargetAccount *Account `json:"target_account"` - // The account of the moderator assigned to this report. - AssignedAccount *Account `json:"assigned_account"` - // The action taken by the moderator who handled the report. - ActionTakenByAccount string `json:"action_taken_by_account"` - // Statuses attached to the report, for context. - Statuses []Status `json:"statuses"` + Report } // AdminEmoji models the admin view of a custom emoji. diff --git a/internal/api/model/report.go b/internal/api/model/report.go new file mode 100644 index 000000000..a994bdf02 --- /dev/null +++ b/internal/api/model/report.go @@ -0,0 +1,97 @@ +/* + 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 . +*/ + +package model + +// Report models a moderation report submitted to the instance, either via the client API or via the federated API. +// +// swagger:model report +type Report struct { + // ID of the report. + // example: 01FBVD42CQ3ZEEVMW180SBX03B + ID string `json:"id"` + // The date when this report was created (ISO 8601 Datetime). + // example: 2021-07-30T09:20:25+00:00 + CreatedAt string `json:"created_at"` + // 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"` + // 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"` + // 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"` + // Array of IDs of statuses that were submitted along with this report. + // Will be empty if no status IDs were submitted. + // example: ["01GPBN5YDY6JKBWE44H7YQBDCQ","01GPBN65PDWSBPWVDD0SQCFFY3"] + StatusIDs []string `json:"status_ids"` + // Array of rule IDs that were submitted along with this report. + // Will be empty if no rule IDs were submitted. + // example: [1, 2] + RuleIDs []int `json:"rule_ids"` + // Account that was reported. + TargetAccount *Account `json:"target_account"` +} + +// ReportCreateRequest models user report creation parameters. +// +// swagger:parameters reportCreate +type ReportCreateRequest struct { + // ID of the account to report. + // example: 01GPE75FXSH2EGFBF85NXPH3KP + // in: formData + // required: true + AccountID string `form:"account_id" json:"account_id" xml:"account_id"` + // IDs of statuses to attach to the report to provide additional context. + // example: ["01GPE76N4SBVRZ8K24TW51ZZQ4","01GPE76WN9JZE62EPT3Q9FRRD4"] + // in: formData + StatusIDs []string `form:"status_ids[]" json:"status_ids" xml:"status_ids"` + // The reason for the report. Default maximum of 1000 characters. + // example: Anti-Blackness, transphobia. + // in: formData + Comment string `form:"comment" json:"comment" xml:"comment"` + // If the account is remote, should the report be forwarded to the remote admin? + // example: true + // default: false + // in: formData + Forward bool `form:"forward" json:"forward" xml:"forward"` + // Specify if the report is due to spam, violation of enumerated instance rules, or some other reason. + // Currently only 'other' is supported. + // example: other + // default: other + // in: formData + Category string `form:"category" json:"category" xml:"category"` + // IDs of rules on this instance which have been broken according to the reporter. + // This is currently not supported, provided only for API compatibility. + // example: [1, 2, 3] + // in: formData + RuleIDs []int `form:"rule_ids[]" json:"rule_ids" xml:"rule_ids"` +} diff --git a/internal/db/bundb/report.go b/internal/db/bundb/report.go index 8cc1d8de9..baf11ddb0 100644 --- a/internal/db/bundb/report.go +++ b/internal/db/bundb/report.go @@ -25,6 +25,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/uptrace/bun" ) @@ -49,6 +50,73 @@ func (r *reportDB) GetReportByID(ctx context.Context, id string) (*gtsmodel.Repo ) } +func (r *reportDB) GetReports(ctx context.Context, resolved *bool, accountID string, targetAccountID string, maxID string, sinceID string, minID string, limit int) ([]*gtsmodel.Report, db.Error) { + reportIDs := []string{} + + q := r.conn. + NewSelect(). + TableExpr("? AS ?", bun.Ident("reports"), bun.Ident("report")). + Column("report.id"). + Order("report.id DESC") + + if resolved != nil { + i := bun.Ident("report.action_taken_by_account_id") + if *resolved { + q = q.Where("? IS NOT NULL", i) + } else { + q = q.Where("? IS NULL", i) + } + } + + if accountID != "" { + q = q.Where("? = ?", bun.Ident("report.account_id"), accountID) + } + + if targetAccountID != "" { + q = q.Where("? = ?", bun.Ident("report.target_account_id"), targetAccountID) + } + + if maxID != "" { + q = q.Where("? < ?", bun.Ident("report.id"), maxID) + } + + if sinceID != "" { + q = q.Where("? > ?", bun.Ident("report.id"), minID) + } + + if minID != "" { + q = q.Where("? > ?", bun.Ident("report.id"), minID) + } + + if limit != 0 { + q = q.Limit(limit) + } + + if err := q.Scan(ctx, &reportIDs); err != nil { + return nil, r.conn.ProcessError(err) + } + + // Catch case of no reports early + if len(reportIDs) == 0 { + return nil, db.ErrNoEntries + } + + // Allocate return slice (will be at most len reportIDs) + reports := make([]*gtsmodel.Report, 0, len(reportIDs)) + for _, id := range reportIDs { + report, err := r.GetReportByID(ctx, id) + if err != nil { + log.Errorf("GetReports: error getting report %q: %v", id, err) + continue + } + + // Append to return slice + reports = append(reports, report) + } + + return reports, nil +} + func (r *reportDB) getReport(ctx context.Context, lookup string, dbQuery func(*gtsmodel.Report) error, keyParts ...any) (*gtsmodel.Report, db.Error) { // Fetch report from database cache with loader callback report, err := r.state.Caches.GTS.Report().Load(lookup, func() (*gtsmodel.Report, error) { diff --git a/internal/db/bundb/report_test.go b/internal/db/bundb/report_test.go index 85bc4b36f..27e4e758c 100644 --- a/internal/db/bundb/report_test.go +++ b/internal/db/bundb/report_test.go @@ -60,6 +60,22 @@ func (suite *ReportTestSuite) TestGetReportByURI() { suite.NotEmpty(report.URI) } +func (suite *ReportTestSuite) TestGetAllReports() { + reports, err := suite.db.GetReports(context.Background(), nil, "", "", "", "", "", 0) + suite.NoError(err) + suite.NotEmpty(reports) +} + +func (suite *ReportTestSuite) TestGetAllReportsByAccountID() { + accountID := suite.testAccounts["local_account_2"].ID + reports, err := suite.db.GetReports(context.Background(), nil, accountID, "", "", "", "", 0) + suite.NoError(err) + suite.NotEmpty(reports) + for _, r := range reports { + suite.Equal(accountID, r.AccountID) + } +} + func (suite *ReportTestSuite) TestPutReport() { ctx := context.Background() diff --git a/internal/db/report.go b/internal/db/report.go index 216e10fdd..194d00b4f 100644 --- a/internal/db/report.go +++ b/internal/db/report.go @@ -28,6 +28,9 @@ import ( type Report interface { // GetReportByID gets one report by its db id GetReportByID(ctx context.Context, id string) (*gtsmodel.Report, Error) + // GetReports gets limit n reports using the given parameters. + // Parameters that are empty / zero are ignored. + GetReports(ctx context.Context, resolved *bool, accountID string, targetAccountID string, maxID string, sinceID string, minID string, limit int) ([]*gtsmodel.Report, Error) // PutReport puts the given report in the database. PutReport(ctx context.Context, report *gtsmodel.Report) Error // UpdateReport updates one report by its db id. diff --git a/internal/processing/fromclientapi.go b/internal/processing/fromclientapi.go index d2512632e..997e76691 100644 --- a/internal/processing/fromclientapi.go +++ b/internal/processing/fromclientapi.go @@ -121,6 +121,12 @@ func (p *processor) ProcessFromClientAPI(ctx context.Context, clientMsg messages // DELETE ACCOUNT/PROFILE return p.processDeleteAccountFromClientAPI(ctx, clientMsg) } + case ap.ActivityFlag: + // FLAG + if clientMsg.APObjectType == ap.ObjectProfile { + // FLAG/REPORT A PROFILE + return p.processReportAccountFromClientAPI(ctx, clientMsg) + } } return nil } @@ -338,6 +344,13 @@ func (p *processor) processDeleteAccountFromClientAPI(ctx context.Context, clien return p.accountProcessor.Delete(ctx, clientMsg.TargetAccount, origin) } +func (p *processor) processReportAccountFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { + // TODO: in a separate PR, handle side effects of flag/report + // 1. email admin(s) + // 2. federate report if necessary + return nil +} + // TODO: move all the below functions into federation.Federator func (p *processor) federateAccountDelete(ctx context.Context, account *gtsmodel.Account) error { diff --git a/internal/processing/processor.go b/internal/processing/processor.go index 0967ab9d2..692523042 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -38,6 +38,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/processing/admin" federationProcessor "github.com/superseriousbusiness/gotosocial/internal/processing/federation" mediaProcessor "github.com/superseriousbusiness/gotosocial/internal/processing/media" + "github.com/superseriousbusiness/gotosocial/internal/processing/report" "github.com/superseriousbusiness/gotosocial/internal/processing/status" "github.com/superseriousbusiness/gotosocial/internal/processing/streaming" "github.com/superseriousbusiness/gotosocial/internal/processing/user" @@ -232,6 +233,13 @@ type Processor interface { // The user belonging to the confirmed email is also returned. UserConfirmEmail(ctx context.Context, token string) (*gtsmodel.User, gtserror.WithCode) + // ReportsGet returns reports created by the given user. + ReportsGet(ctx context.Context, authed *oauth.Auth, resolved *bool, targetAccountID string, maxID string, sinceID string, minID string, limit int) (*apimodel.PageableResponse, gtserror.WithCode) + // ReportGet returns one report created by the given user. + ReportGet(ctx context.Context, authed *oauth.Auth, id string) (*apimodel.Report, gtserror.WithCode) + // ReportCreate creates a new report using the given account and form. + ReportCreate(ctx context.Context, authed *oauth.Auth, form *apimodel.ReportCreateRequest) (*apimodel.Report, gtserror.WithCode) + /* FEDERATION API-FACING PROCESSING FUNCTIONS These functions are intended to be called when the federating client needs an immediate (ie., synchronous) reply @@ -303,6 +311,7 @@ type processor struct { mediaProcessor mediaProcessor.Processor userProcessor user.Processor federationProcessor federationProcessor.Processor + reportProcessor report.Processor } // NewProcessor returns a new Processor. @@ -326,6 +335,7 @@ func NewProcessor( mediaProcessor := mediaProcessor.New(db, tc, mediaManager, federator.TransportController(), storage) userProcessor := user.New(db, emailSender) federationProcessor := federationProcessor.New(db, tc, federator) + reportProcessor := report.New(db, tc, clientWorker) filter := visibility.NewFilter(db) return &processor{ @@ -348,6 +358,7 @@ func NewProcessor( mediaProcessor: mediaProcessor, userProcessor: userProcessor, federationProcessor: federationProcessor, + reportProcessor: reportProcessor, } } diff --git a/internal/processing/report.go b/internal/processing/report.go new file mode 100644 index 000000000..9bbaa3226 --- /dev/null +++ b/internal/processing/report.go @@ -0,0 +1,39 @@ +/* + 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 . +*/ + +package processing + +import ( + "context" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +func (p *processor) ReportsGet(ctx context.Context, authed *oauth.Auth, resolved *bool, targetAccountID string, maxID string, sinceID string, minID string, limit int) (*apimodel.PageableResponse, gtserror.WithCode) { + return p.reportProcessor.ReportsGet(ctx, authed.Account, resolved, targetAccountID, maxID, sinceID, minID, limit) +} + +func (p *processor) ReportGet(ctx context.Context, authed *oauth.Auth, id string) (*apimodel.Report, gtserror.WithCode) { + return p.reportProcessor.ReportGet(ctx, authed.Account, id) +} + +func (p *processor) ReportCreate(ctx context.Context, authed *oauth.Auth, form *apimodel.ReportCreateRequest) (*apimodel.Report, gtserror.WithCode) { + return p.reportProcessor.Create(ctx, authed.Account, form) +} diff --git a/internal/processing/report/create.go b/internal/processing/report/create.go new file mode 100644 index 000000000..ac4c4390d --- /dev/null +++ b/internal/processing/report/create.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 . +*/ + +package report + +import ( + "context" + "errors" + "fmt" + + "github.com/superseriousbusiness/gotosocial/internal/ap" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/messages" + "github.com/superseriousbusiness/gotosocial/internal/uris" +) + +func (p *processor) Create(ctx context.Context, account *gtsmodel.Account, form *apimodel.ReportCreateRequest) (*apimodel.Report, gtserror.WithCode) { + if account.ID == form.AccountID { + err := errors.New("cannot report your own account") + return nil, gtserror.NewErrorBadRequest(err, err.Error()) + } + + // validate + fetch target account + targetAccount, err := p.db.GetAccountByID(ctx, form.AccountID) + if err != nil { + if errors.Is(err, db.ErrNoEntries) { + err = fmt.Errorf("account with ID %s does not exist", form.AccountID) + return nil, gtserror.NewErrorBadRequest(err, err.Error()) + } + err = fmt.Errorf("db error fetching report target account: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // fetch statuses by IDs given in the report form (noop if no statuses given) + statuses, err := p.db.GetStatuses(ctx, form.StatusIDs) + if err != nil { + err = fmt.Errorf("db error fetching report target statuses: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + for _, s := range statuses { + if s.AccountID != form.AccountID { + err = fmt.Errorf("status with ID %s does not belong to account %s", s.ID, form.AccountID) + return nil, gtserror.NewErrorBadRequest(err, err.Error()) + } + } + + reportID, err := id.NewULID() + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + report := >smodel.Report{ + ID: reportID, + URI: uris.GenerateURIForReport(reportID), + AccountID: account.ID, + Account: account, + TargetAccountID: form.AccountID, + TargetAccount: targetAccount, + Comment: form.Comment, + StatusIDs: form.StatusIDs, + Statuses: statuses, + Forwarded: &form.Forward, + } + + if err := p.db.PutReport(ctx, report); err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + p.clientWorker.Queue(messages.FromClientAPI{ + APObjectType: ap.ObjectProfile, + APActivityType: ap.ActivityFlag, + GTSModel: report, + OriginAccount: account, + }) + + apiReport, err := p.tc.ReportToAPIReport(ctx, report) + if err != nil { + err = fmt.Errorf("error converting report to frontend representation: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return apiReport, nil +} diff --git a/internal/processing/report/getreport.go b/internal/processing/report/getreport.go new file mode 100644 index 000000000..6d4a18daa --- /dev/null +++ b/internal/processing/report/getreport.go @@ -0,0 +1,51 @@ +/* + 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 . +*/ + +package report + +import ( + "context" + "fmt" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (p *processor) ReportGet(ctx context.Context, account *gtsmodel.Account, id string) (*apimodel.Report, 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) + } + + if report.AccountID != account.ID { + err = fmt.Errorf("report with id %s does not belong to account %s", report.ID, account.ID) + return nil, gtserror.NewErrorNotFound(err) + } + + apiReport, err := p.tc.ReportToAPIReport(ctx, report) + if err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting report to api: %s", err)) + } + + return apiReport, nil +} diff --git a/internal/processing/report/getreports.go b/internal/processing/report/getreports.go new file mode 100644 index 000000000..e58e847a2 --- /dev/null +++ b/internal/processing/report/getreports.go @@ -0,0 +1,79 @@ +/* + 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 . +*/ + +package report + +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, targetAccountID string, maxID string, sinceID string, minID string, limit int) (*apimodel.PageableResponse, gtserror.WithCode) { + reports, err := p.db.GetReports(ctx, resolved, account.ID, 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.ReportToAPIReport(ctx, r) + 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 targetAccountID != "" { + extraQueryParams = append(extraQueryParams, "target_account_id="+targetAccountID) + } + + return util.PackagePageableResponse(util.PageableResponseParams{ + Items: items, + Path: "/api/v1/reports", + NextMaxIDValue: nextMaxIDValue, + PrevMinIDValue: prevMinIDValue, + Limit: limit, + ExtraQueryParams: extraQueryParams, + }) +} diff --git a/internal/processing/report/report.go b/internal/processing/report/report.go new file mode 100644 index 000000000..8658ac808 --- /dev/null +++ b/internal/processing/report/report.go @@ -0,0 +1,51 @@ +/* + 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 . +*/ + +package report + +import ( + "context" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/concurrency" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/messages" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +type Processor interface { + ReportsGet(ctx context.Context, account *gtsmodel.Account, resolved *bool, targetAccountID string, maxID string, sinceID string, minID string, limit int) (*apimodel.PageableResponse, gtserror.WithCode) + ReportGet(ctx context.Context, account *gtsmodel.Account, id string) (*apimodel.Report, gtserror.WithCode) + Create(ctx context.Context, account *gtsmodel.Account, form *apimodel.ReportCreateRequest) (*apimodel.Report, gtserror.WithCode) +} + +type processor struct { + db db.DB + tc typeutils.TypeConverter + clientWorker *concurrency.WorkerPool[messages.FromClientAPI] +} + +func New(db db.DB, tc typeutils.TypeConverter, clientWorker *concurrency.WorkerPool[messages.FromClientAPI]) Processor { + return &processor{ + tc: tc, + db: db, + clientWorker: clientWorker, + } +} diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go index 2eab1761a..be05a8a48 100644 --- a/internal/typeutils/converter.go +++ b/internal/typeutils/converter.go @@ -87,6 +87,8 @@ type TypeConverter interface { NotificationToAPINotification(ctx context.Context, n *gtsmodel.Notification) (*apimodel.Notification, error) // DomainBlockToAPIDomainBlock converts a gts model domin block into a api domain block, for serving at /api/v1/admin/domain_blocks 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) /* INTERNAL (gts) MODEL TO FRONTEND (rss) MODEL diff --git a/internal/typeutils/converter_test.go b/internal/typeutils/converter_test.go index 8030a1d20..c6f3c2579 100644 --- a/internal/typeutils/converter_test.go +++ b/internal/typeutils/converter_test.go @@ -475,6 +475,7 @@ type TypeUtilsTestSuite struct { testAttachments map[string]*gtsmodel.MediaAttachment testPeople map[string]vocab.ActivityStreamsPerson testEmojis map[string]*gtsmodel.Emoji + testReports map[string]*gtsmodel.Report typeconverter typeutils.TypeConverter } @@ -489,6 +490,7 @@ func (suite *TypeUtilsTestSuite) SetupSuite() { suite.testAttachments = testrig.NewTestAttachments() suite.testPeople = testrig.NewTestFediPeople() suite.testEmojis = testrig.NewTestEmojis() + suite.testReports = testrig.NewTestReports() suite.typeconverter = typeutils.NewConverter(suite.db) } diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index 00903cfe0..dbd1a3822 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -807,6 +807,44 @@ func (c *converter) DomainBlockToAPIDomainBlock(ctx context.Context, b *gtsmodel return domainBlock, nil } +func (c *converter) ReportToAPIReport(ctx context.Context, r *gtsmodel.Report) (*apimodel.Report, error) { + report := &apimodel.Report{ + ID: r.ID, + CreatedAt: util.FormatISO8601(r.CreatedAt), + ActionTaken: !r.ActionTakenAt.IsZero(), + Category: "other", // todo: only support default 'other' category right now + Comment: r.Comment, + Forwarded: *r.Forwarded, + StatusIDs: r.StatusIDs, + RuleIDs: []int{}, // todo: not supported yet + } + + if !r.ActionTakenAt.IsZero() { + actionTakenAt := util.FormatISO8601(r.ActionTakenAt) + report.ActionTakenAt = &actionTakenAt + } + + if actionComment := r.ActionTaken; actionComment != "" { + report.ActionComment = &actionComment + } + + if r.TargetAccount == nil { + tAccount, err := c.db.GetAccountByID(ctx, r.TargetAccountID) + if err != nil { + return nil, fmt.Errorf("ReportToAPIReport: error getting target account with id %s from the db: %s", r.TargetAccountID, err) + } + r.TargetAccount = tAccount + } + + apiAccount, err := c.AccountToAPIAccountPublic(ctx, r.TargetAccount) + if err != nil { + return nil, fmt.Errorf("ReportToAPIReport: error converting target account to api: %s", err) + } + report.TargetAccount = apiAccount + + return report, 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 88c9d46df..7fd08ee05 100644 --- a/internal/typeutils/internaltofrontend_test.go +++ b/internal/typeutils/internaltofrontend_test.go @@ -604,6 +604,93 @@ func (suite *InternalToFrontendTestSuite) TestEmojiToFrontendAdmin2() { }`, string(b)) } +func (suite *InternalToFrontendTestSuite) TestReportToFrontend1() { + report, err := suite.typeconverter.ReportToAPIReport(context.Background(), suite.testReports["local_account_2_report_remote_account_1"]) + suite.NoError(err) + + b, err := json.MarshalIndent(report, "", " ") + suite.NoError(err) + + suite.Equal(`{ + "id": "01GP3AWY4CRDVRNZKW0TEAMB5R", + "created_at": "2022-05-14T10:20:03.000Z", + "action_taken": false, + "action_taken_at": null, + "action_taken_comment": null, + "category": "other", + "comment": "dark souls sucks, please yeet this nerd", + "forwarded": true, + "status_ids": [ + "01FVW7JHQFSFK166WWKR8CBA6M" + ], + "rule_ids": [], + "target_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": [] + } +}`, string(b)) +} + +func (suite *InternalToFrontendTestSuite) TestReportToFrontend2() { + report, err := suite.typeconverter.ReportToAPIReport(context.Background(), suite.testReports["remote_account_1_report_local_account_2"]) + suite.NoError(err) + + b, err := json.MarshalIndent(report, "", " ") + suite.NoError(err) + + suite.Equal(`{ + "id": "01GP3DFY9XQ1TJMZT5BGAZPXX7", + "created_at": "2022-05-15T14:20:12.000Z", + "action_taken": true, + "action_taken_at": "2022-05-15T15:01:56.000Z", + "action_taken_comment": "user was warned not to be a turtle anymore", + "category": "other", + "comment": "this is a turtle, not a person, therefore should not be a poster", + "forwarded": true, + "status_ids": [], + "rule_ids": [], + "target_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" + } +}`, string(b)) +} + func TestInternalToFrontendTestSuite(t *testing.T) { suite.Run(t, new(InternalToFrontendTestSuite)) }