From 993aae5e48a5a3b47a7c7bb3cb66e2d8abda17b2 Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Wed, 25 Jan 2023 11:12:27 +0100 Subject: [PATCH] [feature] Accept incoming federated Flag activity (#1382) * start working on handling incoming Flag activity * interim commit * federate Flag in successfully --- internal/ap/extract.go | 17 ++ internal/ap/interfaces.go | 10 + internal/federation/federatingdb/create.go | 38 +++ .../federation/federatingdb/create_test.go | 46 +++ internal/processing/fromfederator.go | 10 + internal/regexes/regexes.go | 4 + internal/typeutils/astointernal.go | 125 ++++++++ internal/typeutils/astointernal_test.go | 268 +++++++++++++++--- internal/typeutils/converter.go | 2 + internal/typeutils/util.go | 43 ++- internal/typeutils/util_test.go | 47 +++ 11 files changed, 560 insertions(+), 50 deletions(-) create mode 100644 internal/typeutils/util_test.go diff --git a/internal/ap/extract.go b/internal/ap/extract.go index c09f07c41..20420e02c 100644 --- a/internal/ap/extract.go +++ b/internal/ap/extract.go @@ -635,6 +635,23 @@ func ExtractObject(i WithObject) (*url.URL, error) { return nil, errors.New("no iri found for object prop") } +// ExtractObjects extracts a slice of URL objects from a WithObject interface. +func ExtractObjects(i WithObject) ([]*url.URL, error) { + objectProp := i.GetActivityStreamsObject() + if objectProp == nil { + return nil, errors.New("object property was nil") + } + + urls := make([]*url.URL, 0, objectProp.Len()) + for iter := objectProp.Begin(); iter != objectProp.End(); iter = iter.Next() { + if iter.IsIRI() && iter.GetIRI() != nil { + urls = append(urls, iter.GetIRI()) + } + } + + return urls, nil +} + // ExtractVisibility extracts the gtsmodel.Visibility of a given addressable with a To and CC property. // // ActorFollowersURI is needed to check whether the visibility is FollowersOnly or not. The passed-in value diff --git a/internal/ap/interfaces.go b/internal/ap/interfaces.go index 91960eed3..a538e4c2b 100644 --- a/internal/ap/interfaces.go +++ b/internal/ap/interfaces.go @@ -157,6 +157,16 @@ type CollectionPageable interface { WithItems } +// Flaggable represents the minimum interface for an activitystreams 'Flag' activity. +type Flaggable interface { + WithJSONLDId + WithTypeName + + WithActor + WithContent + WithObject +} + // WithJSONLDId represents an activity with JSONLDIdProperty type WithJSONLDId interface { GetJSONLDId() vocab.JSONLDIdProperty diff --git a/internal/federation/federatingdb/create.go b/internal/federation/federatingdb/create.go index 05321bd6c..fbe3d3ad6 100644 --- a/internal/federation/federatingdb/create.go +++ b/internal/federation/federatingdb/create.go @@ -78,6 +78,9 @@ func (f *federatingDB) Create(ctx context.Context, asType vocab.Type) error { case ap.ActivityLike: // LIKE SOMETHING return f.activityLike(ctx, asType, receivingAccount, requestingAccount) + case ap.ActivityFlag: + // FLAG / REPORT SOMETHING + return f.activityFlag(ctx, asType, receivingAccount, requestingAccount) } return nil } @@ -314,3 +317,38 @@ func (f *federatingDB) activityLike(ctx context.Context, asType vocab.Type, rece return nil } + +/* + FLAG HANDLERS +*/ + +func (f *federatingDB) activityFlag(ctx context.Context, asType vocab.Type, receivingAccount *gtsmodel.Account, requestingAccount *gtsmodel.Account) error { + flag, ok := asType.(vocab.ActivityStreamsFlag) + if !ok { + return errors.New("activityFlag: could not convert type to flag") + } + + report, err := f.typeConverter.ASFlagToReport(ctx, flag) + if err != nil { + return fmt.Errorf("activityFlag: could not convert Flag to report: %w", err) + } + + newID, err := id.NewULID() + if err != nil { + return err + } + report.ID = newID + + if err := f.db.PutReport(ctx, report); err != nil { + return fmt.Errorf("activityFlag: database error inserting report: %w", err) + } + + f.fedWorker.Queue(messages.FromFederator{ + APObjectType: ap.ActivityFlag, + APActivityType: ap.ActivityCreate, + GTSModel: report, + ReceivingAccount: receivingAccount, + }) + + return nil +} diff --git a/internal/federation/federatingdb/create_test.go b/internal/federation/federatingdb/create_test.go index 6bab42889..c56dcd864 100644 --- a/internal/federation/federatingdb/create_test.go +++ b/internal/federation/federatingdb/create_test.go @@ -20,9 +20,11 @@ package federatingdb_test import ( "context" + "encoding/json" "testing" "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/activity/streams" "github.com/superseriousbusiness/gotosocial/internal/ap" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) @@ -83,6 +85,50 @@ func (suite *CreateTestSuite) TestCreateNoteForward() { suite.Equal("http://example.org/users/Some_User/statuses/afaba698-5740-4e32-a702-af61aa543bc1", msg.APIri.String()) } +func (suite *CreateTestSuite) TestCreateFlag1() { + reportedAccount := suite.testAccounts["local_account_1"] + reportingAccount := suite.testAccounts["remote_account_1"] + reportedStatus := suite.testStatuses["local_account_1_status_1"] + + raw := `{ + "@context": "https://www.w3.org/ns/activitystreams", + "actor": "` + reportingAccount.URI + `", + "content": "Note: ` + reportedStatus.URL + `\n-----\nban this sick filth ⛔", + "id": "http://fossbros-anonymous.io/db22128d-884e-4358-9935-6a7c3940535d", + "object": "` + reportedAccount.URI + `", + "type": "Flag" +}` + + m := make(map[string]interface{}) + if err := json.Unmarshal([]byte(raw), &m); err != nil { + suite.FailNow(err.Error()) + } + + t, err := streams.ToType(context.Background(), m) + if err != nil { + suite.FailNow(err.Error()) + } + + ctx := createTestContext(reportedAccount, reportingAccount) + if err := suite.federatingDB.Create(ctx, t); err != nil { + suite.FailNow(err.Error()) + } + + // should be a message heading to the processor now, which we can intercept here + msg := <-suite.fromFederator + suite.Equal(ap.ActivityFlag, msg.APObjectType) + suite.Equal(ap.ActivityCreate, msg.APActivityType) + + // shiny new report should be defined on the message + suite.NotNil(msg.GTSModel) + report := msg.GTSModel.(*gtsmodel.Report) + + // report should be in the database + if _, err := suite.db.GetReportByID(context.Background(), report.ID); err != nil { + suite.FailNow(err.Error()) + } +} + func TestCreateTestSuite(t *testing.T) { suite.Run(t, &CreateTestSuite{}) } diff --git a/internal/processing/fromfederator.go b/internal/processing/fromfederator.go index 51e464f3e..c30fc2692 100644 --- a/internal/processing/fromfederator.go +++ b/internal/processing/fromfederator.go @@ -82,6 +82,9 @@ func (p *processor) ProcessFromFederator(ctx context.Context, federatorMsg messa case ap.ActivityBlock: // CREATE A BLOCK return p.processCreateBlockFromFederator(ctx, federatorMsg) + case ap.ActivityFlag: + // CREATE A FLAG / REPORT + return p.processCreateFlagFromFederator(ctx, federatorMsg) } case ap.ActivityUpdate: // UPDATE SOMETHING @@ -357,6 +360,13 @@ func (p *processor) processCreateBlockFromFederator(ctx context.Context, federat return nil } +func (p *processor) processCreateFlagFromFederator(ctx context.Context, federatorMsg messages.FromFederator) error { + // TODO: handle side effects of flag creation: + // - send email to admins + // - notify admins + return nil +} + // processUpdateAccountFromFederator handles Activity Update and Object Profile func (p *processor) processUpdateAccountFromFederator(ctx context.Context, federatorMsg messages.FromFederator) error { incomingAccount, ok := federatorMsg.GTSModel.(*gtsmodel.Account) diff --git a/internal/regexes/regexes.go b/internal/regexes/regexes.go index 4c9d48dac..06fb92b41 100644 --- a/internal/regexes/regexes.go +++ b/internal/regexes/regexes.go @@ -150,6 +150,10 @@ var ( // It captures the account id, media type, media size, file name, and file extension, eg // `01F8MH1H7YV1Z7D2C8K2730QBF`, `attachment`, `small`, `01F8MH8RMYQ6MSNY3JM2XT1CQ5`, `jpeg`. FilePath = regexp.MustCompile(filePath) + + // MisskeyReportNotes captures a list of Note URIs from report content created by Misskey. + // https://regex101.com/r/EnTOBV/1 + MisskeyReportNotes = regexp.MustCompile(`(?m)(?:^Note: ((?:http|https):\/\/.*)$)`) ) // bufpool is a memory pool of byte buffers for use in our regex utility functions. diff --git a/internal/typeutils/astointernal.go b/internal/typeutils/astointernal.go index cd1b0c77a..bebf62989 100644 --- a/internal/typeutils/astointernal.go +++ b/internal/typeutils/astointernal.go @@ -22,12 +22,15 @@ import ( "context" "errors" "fmt" + "net/url" "github.com/miekg/dns" "github.com/superseriousbusiness/gotosocial/internal/ap" + "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/uris" ) func (c *converter) ASRepresentationToAccount(ctx context.Context, accountable ap.Accountable, accountDomain string, update bool) (*gtsmodel.Account, error) { @@ -574,3 +577,125 @@ func (c *converter) ASAnnounceToStatus(ctx context.Context, announceable ap.Anno // the rest of the fields will be taken from the target status, but it's not our job to do the dereferencing here return status, isNew, nil } + +func (c *converter) ASFlagToReport(ctx context.Context, flaggable ap.Flaggable) (*gtsmodel.Report, error) { + // Extract flag uri. + idProp := flaggable.GetJSONLDId() + if idProp == nil || !idProp.IsIRI() { + return nil, errors.New("ASFlagToReport: no id property set on flaggable, or was not an iri") + } + uri := idProp.GetIRI().String() + + // Extract account that created the flag / report. + // This will usually be an instance actor. + actor, err := ap.ExtractActor(flaggable) + if err != nil { + return nil, fmt.Errorf("ASFlagToReport: error extracting actor: %w", err) + } + account, err := c.db.GetAccountByURI(ctx, actor.String()) + if err != nil { + return nil, fmt.Errorf("ASFlagToReport: error in db fetching account with uri %s: %w", actor.String(), err) + } + + // Get the content of the report. + // For Mastodon, this will just be a string, or nothing. + // In Misskey's case, it may also contain the URLs of + // one or more reported statuses, so extract these too. + content := ap.ExtractContent(flaggable) + statusURIs := []*url.URL{} + inlineURLs := misskeyReportInlineURLs(content) + statusURIs = append(statusURIs, inlineURLs...) + + // Extract account and statuses targeted by the flag / report. + // + // Incoming flags from mastodon usually have a target account uri as + // first entry in objects, followed by URIs of one or more statuses. + // Misskey on the other hand will just contain the target account uri. + // We shouldn't assume the order of the objects will correspond to this, + // but we can check that he objects slice contains just one account, and + // maybe some statuses. + // + // Throw away anything that's not relevant to us. + objects, err := ap.ExtractObjects(flaggable) + if err != nil { + return nil, fmt.Errorf("ASFlagToReport: error extracting objects: %w", err) + } + if len(objects) == 0 { + return nil, errors.New("ASFlagToReport: flaggable objects empty, can't create report") + } + + var targetAccountURI *url.URL + for _, object := range objects { + switch { + case object.Host != config.GetHost(): + // object doesn't belong to us, just ignore it + continue + case uris.IsUserPath(object): + if targetAccountURI != nil { + return nil, errors.New("ASFlagToReport: flaggable objects contained more than one target account uri") + } + targetAccountURI = object + case uris.IsStatusesPath(object): + statusURIs = append(statusURIs, object) + } + } + + // Make sure we actually have a target account now. + if targetAccountURI == nil { + return nil, errors.New("ASFlagToReport: flaggable objects contained no recognizable target account uri") + } + targetAccount, err := c.db.GetAccountByURI(ctx, targetAccountURI.String()) + if err != nil { + if errors.Is(err, db.ErrNoEntries) { + return nil, fmt.Errorf("ASFlagToReport: account with uri %s could not be found in the db", targetAccountURI.String()) + } + return nil, fmt.Errorf("ASFlagToReport: db error getting account with uri %s: %w", targetAccountURI.String(), err) + } + + // If we got some status URIs, try to get them from the db now + var ( + statusIDs = make([]string, 0, len(statusURIs)) + statuses = make([]*gtsmodel.Status, 0, len(statusURIs)) + ) + for _, statusURI := range statusURIs { + statusURIString := statusURI.String() + + // try getting this status by URI first, then URL + status, err := c.db.GetStatusByURI(ctx, statusURIString) + if err != nil { + if !errors.Is(err, db.ErrNoEntries) { + return nil, fmt.Errorf("ASFlagToReport: db error getting status with uri %s: %w", statusURIString, err) + } + + status, err = c.db.GetStatusByURL(ctx, statusURIString) + if err != nil { + if !errors.Is(err, db.ErrNoEntries) { + return nil, fmt.Errorf("ASFlagToReport: db error getting status with url %s: %w", statusURIString, err) + } + + log.Warnf("ASFlagToReport: reported status %s could not be found in the db, skipping it", statusURIString) + continue + } + } + + if status.AccountID != targetAccount.ID { + // status doesn't belong to this account, ignore it + continue + } + + statusIDs = append(statusIDs, status.ID) + statuses = append(statuses, status) + } + + // id etc should be handled the caller, so just return what we got + return >smodel.Report{ + URI: uri, + AccountID: account.ID, + Account: account, + TargetAccountID: targetAccount.ID, + TargetAccount: targetAccount, + Comment: content, + StatusIDs: statusIDs, + Statuses: statuses, + }, nil +} diff --git a/internal/typeutils/astointernal_test.go b/internal/typeutils/astointernal_test.go index 1731a8f3a..eb7ed12fc 100644 --- a/internal/typeutils/astointernal_test.go +++ b/internal/typeutils/astointernal_test.go @@ -35,6 +35,20 @@ type ASToInternalTestSuite struct { TypeUtilsTestSuite } +func (suite *ASToInternalTestSuite) jsonToType(in string) vocab.Type { + m := make(map[string]interface{}) + if err := json.Unmarshal([]byte(in), &m); err != nil { + suite.FailNow(err.Error()) + } + + t, err := streams.ToType(context.Background(), m) + if err != nil { + suite.FailNow(err.Error()) + } + + return t +} + func (suite *ASToInternalTestSuite) TestParsePerson() { testPerson := suite.testPeople["https://unknown-instance.com/users/brand_new_person"] @@ -80,15 +94,11 @@ func (suite *ASToInternalTestSuite) TestParsePersonWithSharedInbox() { } func (suite *ASToInternalTestSuite) TestParsePublicStatus() { - m := make(map[string]interface{}) - err := json.Unmarshal([]byte(publicStatusActivityJson), &m) - suite.NoError(err) - - t, err := streams.ToType(context.Background(), m) - suite.NoError(err) - + t := suite.jsonToType(publicStatusActivityJson) rep, ok := t.(ap.Statusable) - suite.True(ok) + if !ok { + suite.FailNow("type not coercible") + } status, err := suite.typeconverter.ASStatusToStatus(context.Background(), rep) suite.NoError(err) @@ -98,15 +108,11 @@ func (suite *ASToInternalTestSuite) TestParsePublicStatus() { } func (suite *ASToInternalTestSuite) TestParsePublicStatusNoURL() { - m := make(map[string]interface{}) - err := json.Unmarshal([]byte(publicStatusActivityJsonNoURL), &m) - suite.NoError(err) - - t, err := streams.ToType(context.Background(), m) - suite.NoError(err) - + t := suite.jsonToType(publicStatusActivityJsonNoURL) rep, ok := t.(ap.Statusable) - suite.True(ok) + if !ok { + suite.FailNow("type not coercible") + } status, err := suite.typeconverter.ASStatusToStatus(context.Background(), rep) suite.NoError(err) @@ -119,35 +125,23 @@ func (suite *ASToInternalTestSuite) TestParsePublicStatusNoURL() { } func (suite *ASToInternalTestSuite) TestParseGargron() { - m := make(map[string]interface{}) - err := json.Unmarshal([]byte(gargronAsActivityJson), &m) - suite.NoError(err) - - t, err := streams.ToType(context.Background(), m) - suite.NoError(err) - + t := suite.jsonToType(gargronAsActivityJson) rep, ok := t.(ap.Accountable) - suite.True(ok) + if !ok { + suite.FailNow("type not coercible") + } acct, err := suite.typeconverter.ASRepresentationToAccount(context.Background(), rep, "", false) suite.NoError(err) - suite.Equal("https://mastodon.social/inbox", *acct.SharedInboxURI) - - fmt.Printf("%+v", acct) - // TODO: write assertions here, rn we're just eyeballing the output } func (suite *ASToInternalTestSuite) TestParseReplyWithMention() { - m := make(map[string]interface{}) - err := json.Unmarshal([]byte(statusWithMentionsActivityJson), &m) - suite.NoError(err) - - t, err := streams.ToType(context.Background(), m) - suite.NoError(err) - + t := suite.jsonToType(statusWithMentionsActivityJson) create, ok := t.(vocab.ActivityStreamsCreate) - suite.True(ok) + if !ok { + suite.FailNow("type not coercible") + } object := create.GetActivityStreamsObject() var status *gtsmodel.Status @@ -183,15 +177,11 @@ func (suite *ASToInternalTestSuite) TestParseReplyWithMention() { } func (suite *ASToInternalTestSuite) TestParseOwncastService() { - m := make(map[string]interface{}) - err := json.Unmarshal([]byte(owncastService), &m) - suite.NoError(err) - - t, err := streams.ToType(context.Background(), m) - suite.NoError(err) - + t := suite.jsonToType(owncastService) rep, ok := t.(ap.Accountable) - suite.True(ok) + if !ok { + suite.FailNow("type not coercible") + } acct, err := suite.typeconverter.ASRepresentationToAccount(context.Background(), rep, "", false) suite.NoError(err) @@ -225,6 +215,196 @@ func (suite *ASToInternalTestSuite) TestParseOwncastService() { fmt.Printf("\n\n\n%s\n\n\n", string(b)) } +func (suite *ASToInternalTestSuite) TestParseFlag1() { + reportedAccount := suite.testAccounts["local_account_1"] + reportingAccount := suite.testAccounts["remote_account_1"] + reportedStatus := suite.testStatuses["local_account_1_status_1"] + + raw := `{ + "@context": "https://www.w3.org/ns/activitystreams", + "actor": "` + reportingAccount.URI + `", + "content": "Note: ` + reportedStatus.URL + `\n-----\nban this sick filth ⛔", + "id": "http://fossbros-anonymous.io/db22128d-884e-4358-9935-6a7c3940535d", + "object": "` + reportedAccount.URI + `", + "type": "Flag" +}` + + t := suite.jsonToType(raw) + asFlag, ok := t.(ap.Flaggable) + if !ok { + suite.FailNow("type not coercible") + } + + report, err := suite.typeconverter.ASFlagToReport(context.Background(), asFlag) + if err != nil { + suite.FailNow(err.Error()) + } + + suite.Equal(report.AccountID, reportingAccount.ID) + suite.Equal(report.TargetAccountID, reportedAccount.ID) + suite.Len(report.StatusIDs, 1) + suite.Len(report.Statuses, 1) + suite.Equal(report.Statuses[0].ID, reportedStatus.ID) + suite.Equal(report.Comment, "Note: "+reportedStatus.URL+"\n-----\nban this sick filth ⛔") +} + +func (suite *ASToInternalTestSuite) TestParseFlag2() { + reportedAccount := suite.testAccounts["local_account_1"] + reportingAccount := suite.testAccounts["remote_account_1"] + // report a status that doesn't exist + reportedStatusURL := "http://localhost:8080/@the_mighty_zork/01GQHR6MCQSTCP85ZG4A0VR316" + + raw := `{ + "@context": "https://www.w3.org/ns/activitystreams", + "actor": "` + reportingAccount.URI + `", + "content": "Note: ` + reportedStatusURL + `\n-----\nban this sick filth ⛔", + "id": "http://fossbros-anonymous.io/db22128d-884e-4358-9935-6a7c3940535d", + "object": "` + reportedAccount.URI + `", + "type": "Flag" +}` + + t := suite.jsonToType(raw) + asFlag, ok := t.(ap.Flaggable) + if !ok { + suite.FailNow("type not coercible") + } + + report, err := suite.typeconverter.ASFlagToReport(context.Background(), asFlag) + if err != nil { + suite.FailNow(err.Error()) + } + + suite.Equal(report.AccountID, reportingAccount.ID) + suite.Equal(report.TargetAccountID, reportedAccount.ID) + + // nonexistent status should just be skipped, it'll still be in the content though + suite.Len(report.StatusIDs, 0) + suite.Len(report.Statuses, 0) + suite.Equal(report.Comment, "Note: "+reportedStatusURL+"\n-----\nban this sick filth ⛔") +} + +func (suite *ASToInternalTestSuite) TestParseFlag3() { + // flag an account that doesn't exist + reportedAccountURI := "http://localhost:8080/users/mr_e_man" + reportingAccount := suite.testAccounts["remote_account_1"] + + raw := `{ + "@context": "https://www.w3.org/ns/activitystreams", + "actor": "` + reportingAccount.URI + `", + "content": "ban this sick filth ⛔", + "id": "http://fossbros-anonymous.io/db22128d-884e-4358-9935-6a7c3940535d", + "object": "` + reportedAccountURI + `", + "type": "Flag" +}` + + t := suite.jsonToType(raw) + asFlag, ok := t.(ap.Flaggable) + if !ok { + suite.FailNow("type not coercible") + } + + report, err := suite.typeconverter.ASFlagToReport(context.Background(), asFlag) + suite.Nil(report) + suite.EqualError(err, "ASFlagToReport: account with uri http://localhost:8080/users/mr_e_man could not be found in the db") +} + +func (suite *ASToInternalTestSuite) TestParseFlag4() { + // flag an account from another instance + reportingAccount := suite.testAccounts["remote_account_1"] + reportedAccountURI := suite.testAccounts["remote_account_2"].URI + + raw := `{ + "@context": "https://www.w3.org/ns/activitystreams", + "actor": "` + reportingAccount.URI + `", + "content": "ban this sick filth ⛔", + "id": "http://fossbros-anonymous.io/db22128d-884e-4358-9935-6a7c3940535d", + "object": "` + reportedAccountURI + `", + "type": "Flag" +}` + + t := suite.jsonToType(raw) + asFlag, ok := t.(ap.Flaggable) + if !ok { + suite.FailNow("type not coercible") + } + + report, err := suite.typeconverter.ASFlagToReport(context.Background(), asFlag) + suite.Nil(report) + suite.EqualError(err, "ASFlagToReport: flaggable objects contained no recognizable target account uri") +} + +func (suite *ASToInternalTestSuite) TestParseFlag5() { + reportedAccount := suite.testAccounts["local_account_1"] + reportingAccount := suite.testAccounts["remote_account_1"] + reportedStatus := suite.testStatuses["local_account_1_status_1"] + + raw := `{ + "@context": "https://www.w3.org/ns/activitystreams", + "actor": "` + reportingAccount.URI + `", + "content": "misinformation", + "id": "http://fossbros-anonymous.io/db22128d-884e-4358-9935-6a7c3940535d", + "object": [ + "` + reportedAccount.URI + `", + "` + reportedStatus.URI + `" + ], + "type": "Flag" + }` + + t := suite.jsonToType(raw) + asFlag, ok := t.(ap.Flaggable) + if !ok { + suite.FailNow("type not coercible") + } + + report, err := suite.typeconverter.ASFlagToReport(context.Background(), asFlag) + if err != nil { + suite.FailNow(err.Error()) + } + + suite.Equal(report.AccountID, reportingAccount.ID) + suite.Equal(report.TargetAccountID, reportedAccount.ID) + suite.Len(report.StatusIDs, 1) + suite.Len(report.Statuses, 1) + suite.Equal(report.Statuses[0].ID, reportedStatus.ID) + suite.Equal(report.Comment, "misinformation") +} + +func (suite *ASToInternalTestSuite) TestParseFlag6() { + reportedAccount := suite.testAccounts["local_account_1"] + reportingAccount := suite.testAccounts["remote_account_1"] + // flag a status that belongs to another account + reportedStatus := suite.testStatuses["local_account_2_status_1"] + + raw := `{ + "@context": "https://www.w3.org/ns/activitystreams", + "actor": "` + reportingAccount.URI + `", + "content": "misinformation", + "id": "http://fossbros-anonymous.io/db22128d-884e-4358-9935-6a7c3940535d", + "object": [ + "` + reportedAccount.URI + `", + "` + reportedStatus.URI + `" + ], + "type": "Flag" + }` + + t := suite.jsonToType(raw) + asFlag, ok := t.(ap.Flaggable) + if !ok { + suite.FailNow("type not coercible") + } + + report, err := suite.typeconverter.ASFlagToReport(context.Background(), asFlag) + if err != nil { + suite.FailNow(err.Error()) + } + + suite.Equal(report.AccountID, reportingAccount.ID) + suite.Equal(report.TargetAccountID, reportedAccount.ID) + suite.Len(report.StatusIDs, 0) + suite.Len(report.Statuses, 0) + suite.Equal(report.Comment, "misinformation") +} + func TestASToInternalTestSuite(t *testing.T) { suite.Run(t, new(ASToInternalTestSuite)) } diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go index c7fd31470..33ed617e0 100644 --- a/internal/typeutils/converter.go +++ b/internal/typeutils/converter.go @@ -141,6 +141,8 @@ type TypeConverter interface { // // NOTE -- this is different from one status being boosted multiple times! In this case, new boosts should indeed be created. ASAnnounceToStatus(ctx context.Context, announceable ap.Announceable) (status *gtsmodel.Status, new bool, err error) + // ASFlagToReport converts a remote activitystreams 'flag' representation into a gts model report. + ASFlagToReport(ctx context.Context, flaggable ap.Flaggable) (report *gtsmodel.Report, err error) /* INTERNAL (gts) MODEL TO ACTIVITYSTREAMS MODEL diff --git a/internal/typeutils/util.go b/internal/typeutils/util.go index 1d1903afc..40001e913 100644 --- a/internal/typeutils/util.go +++ b/internal/typeutils/util.go @@ -1,12 +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 typeutils import ( "context" "fmt" + "net/url" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/regexes" ) +type statusInteractions struct { + Faved bool + Muted bool + Bookmarked bool + Reblogged bool +} + func (c *converter) interactionsWithStatusForAccount(ctx context.Context, s *gtsmodel.Status, requestingAccount *gtsmodel.Account) (*statusInteractions, error) { si := &statusInteractions{} @@ -38,10 +65,14 @@ func (c *converter) interactionsWithStatusForAccount(ctx context.Context, s *gts return si, nil } -// StatusInteractions denotes interactions with a status on behalf of an account. -type statusInteractions struct { - Faved bool - Muted bool - Bookmarked bool - Reblogged bool +func misskeyReportInlineURLs(content string) []*url.URL { + m := regexes.MisskeyReportNotes.FindAllStringSubmatch(content, -1) + urls := make([]*url.URL, 0, len(m)) + for _, sm := range m { + url, err := url.Parse(sm[1]) + if err == nil && url != nil { + urls = append(urls, url) + } + } + return urls } diff --git a/internal/typeutils/util_test.go b/internal/typeutils/util_test.go new file mode 100644 index 000000000..79f93bdde --- /dev/null +++ b/internal/typeutils/util_test.go @@ -0,0 +1,47 @@ +/* + 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 typeutils + +import ( + "testing" +) + +func TestMisskeyReportContentURLs1(t *testing.T) { + content := `Note: https://bad.instance/@tobi/statuses/01GPB56GPJ37JTK9HW308HQKBQ +Note: https://bad.instance/@tobi/statuses/01GPB56GPJ37JTK9HW308HQKBQ +Note: https://bad.instance/@tobi/statuses/01GPB56GPJ37JTK9HW308HQKBQ +----- +Test report from Calckey` + + urls := misskeyReportInlineURLs(content) + if l := len(urls); l != 3 { + t.Fatalf("wanted 3 urls, got %d", l) + } +} + +func TestMisskeyReportContentURLs2(t *testing.T) { + content := `This is a report +with just a normal url in it: https://example.org, and is not +misskey-formatted` + + urls := misskeyReportInlineURLs(content) + if l := len(urls); l != 0 { + t.Fatalf("wanted 0 urls, got %d", l) + } +}