From 3283900b0d0b98e5ca956f61ce09ab373cf0cbe8 Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Fri, 27 Jan 2023 14:48:11 +0100 Subject: [PATCH] [feature] Federate reports to remote instance as Flag (if desired) (#1386) * reports federate out, we did it lxds * fix optional line start (should be optional slash) --- internal/ap/extract.go | 2 +- internal/federation/federatingdb/get.go | 25 ++++----- internal/federation/federatingdb/util.go | 64 +++++++++++------------ internal/processing/fromclientapi.go | 65 ++++++++++++++++++++++-- internal/processing/report/create.go | 1 + internal/regexes/regexes.go | 31 ++++++----- internal/typeutils/converter.go | 2 + internal/typeutils/internaltoas.go | 50 ++++++++++++++++++ internal/typeutils/internaltoas_test.go | 34 +++++++++++++ testrig/testmodels.go | 2 +- 10 files changed, 207 insertions(+), 69 deletions(-) diff --git a/internal/ap/extract.go b/internal/ap/extract.go index 20420e02c..ab51b0858 100644 --- a/internal/ap/extract.go +++ b/internal/ap/extract.go @@ -621,7 +621,7 @@ func ExtractActor(i WithActor) (*url.URL, error) { return nil, errors.New("no iri found for actor prop") } -// ExtractObject extracts a URL object from a WithObject interface. +// ExtractObject extracts the first URL object from a WithObject interface. func ExtractObject(i WithObject) (*url.URL, error) { objectProp := i.GetActivityStreamsObject() if objectProp == nil { diff --git a/internal/federation/federatingdb/get.go b/internal/federation/federatingdb/get.go index 24f3ddb51..a55cb0280 100644 --- a/internal/federation/federatingdb/get.go +++ b/internal/federation/federatingdb/get.go @@ -20,7 +20,7 @@ package federatingdb import ( "context" - "errors" + "fmt" "net/url" "codeberg.org/gruf/go-kv" @@ -33,34 +33,27 @@ import ( // // The library makes this call only after acquiring a lock first. func (f *federatingDB) Get(ctx context.Context, id *url.URL) (value vocab.Type, err error) { - l := log.WithFields(kv.Fields{ - {"id", id}, - }...) + l := log.WithFields(kv.Fields{{"id", id}}...) l.Debug("entering Get") - if uris.IsUserPath(id) { + switch { + case uris.IsUserPath(id): acct, err := f.db.GetAccountByURI(ctx, id.String()) if err != nil { return nil, err } return f.typeConverter.AccountToAS(ctx, acct) - } - - if uris.IsStatusesPath(id) { + case uris.IsStatusesPath(id): status, err := f.db.GetStatusByURI(ctx, id.String()) if err != nil { return nil, err } return f.typeConverter.StatusToAS(ctx, status) - } - - if uris.IsFollowersPath(id) { + case uris.IsFollowersPath(id): return f.Followers(ctx, id) - } - - if uris.IsFollowingPath(id) { + case uris.IsFollowingPath(id): return f.Following(ctx, id) + default: + return nil, fmt.Errorf("federatingDB: could not Get %s", id.String()) } - - return nil, errors.New("could not get") } diff --git a/internal/federation/federatingdb/util.go b/internal/federation/federatingdb/util.go index a1efad3f0..b5a9feab1 100644 --- a/internal/federation/federatingdb/util.go +++ b/internal/federation/federatingdb/util.go @@ -229,60 +229,56 @@ func (f *federatingDB) ActorForInbox(ctx context.Context, inboxIRI *url.URL) (ac } // getAccountForIRI returns the account that corresponds to or owns the given IRI. -func (f *federatingDB) getAccountForIRI(ctx context.Context, iri *url.URL) (account *gtsmodel.Account, err error) { - acct := >smodel.Account{} +func (f *federatingDB) getAccountForIRI(ctx context.Context, iri *url.URL) (*gtsmodel.Account, error) { + var ( + acct = >smodel.Account{} + err error + ) - if uris.IsInboxPath(iri) { - if err := f.db.GetWhere(ctx, []db.Where{{Key: "inbox_uri", Value: iri.String()}}, acct); err != nil { - if err == db.ErrNoEntries { - return nil, fmt.Errorf("no actor found that corresponds to inbox %s", iri.String()) - } - return nil, fmt.Errorf("db error searching for actor with inbox %s", iri.String()) - } - return acct, nil - } - - if uris.IsOutboxPath(iri) { - if err := f.db.GetWhere(ctx, []db.Where{{Key: "outbox_uri", Value: iri.String()}}, acct); err != nil { - if err == db.ErrNoEntries { - return nil, fmt.Errorf("no actor found that corresponds to outbox %s", iri.String()) - } - return nil, fmt.Errorf("db error searching for actor with outbox %s", iri.String()) - } - return acct, nil - } - - if uris.IsUserPath(iri) { - if err := f.db.GetWhere(ctx, []db.Where{{Key: "uri", Value: iri.String()}}, acct); err != nil { + switch { + case uris.IsUserPath(iri): + if acct, err = f.db.GetAccountByURI(ctx, iri.String()); err != nil { if err == db.ErrNoEntries { return nil, fmt.Errorf("no actor found that corresponds to uri %s", iri.String()) } return nil, fmt.Errorf("db error searching for actor with uri %s", iri.String()) } return acct, nil - } - - if uris.IsFollowersPath(iri) { - if err := f.db.GetWhere(ctx, []db.Where{{Key: "followers_uri", Value: iri.String()}}, acct); err != nil { + case uris.IsInboxPath(iri): + if err = f.db.GetWhere(ctx, []db.Where{{Key: "inbox_uri", Value: iri.String()}}, acct); err != nil { + if err == db.ErrNoEntries { + return nil, fmt.Errorf("no actor found that corresponds to inbox %s", iri.String()) + } + return nil, fmt.Errorf("db error searching for actor with inbox %s", iri.String()) + } + return acct, nil + case uris.IsOutboxPath(iri): + if err = f.db.GetWhere(ctx, []db.Where{{Key: "outbox_uri", Value: iri.String()}}, acct); err != nil { + if err == db.ErrNoEntries { + return nil, fmt.Errorf("no actor found that corresponds to outbox %s", iri.String()) + } + return nil, fmt.Errorf("db error searching for actor with outbox %s", iri.String()) + } + return acct, nil + case uris.IsFollowersPath(iri): + if err = f.db.GetWhere(ctx, []db.Where{{Key: "followers_uri", Value: iri.String()}}, acct); err != nil { if err == db.ErrNoEntries { return nil, fmt.Errorf("no actor found that corresponds to followers_uri %s", iri.String()) } return nil, fmt.Errorf("db error searching for actor with followers_uri %s", iri.String()) } return acct, nil - } - - if uris.IsFollowingPath(iri) { - if err := f.db.GetWhere(ctx, []db.Where{{Key: "following_uri", Value: iri.String()}}, acct); err != nil { + case uris.IsFollowingPath(iri): + if err = f.db.GetWhere(ctx, []db.Where{{Key: "following_uri", Value: iri.String()}}, acct); err != nil { if err == db.ErrNoEntries { return nil, fmt.Errorf("no actor found that corresponds to following_uri %s", iri.String()) } return nil, fmt.Errorf("db error searching for actor with following_uri %s", iri.String()) } return acct, nil + default: + return nil, fmt.Errorf("getActorForIRI: iri %s not recognised", iri) } - - return nil, fmt.Errorf("getActorForIRI: iri %s not recognised", iri) } // collectFollows takes a slice of iris and converts them into ActivityStreamsCollection of IRIs. diff --git a/internal/processing/fromclientapi.go b/internal/processing/fromclientapi.go index 997e76691..6035130e2 100644 --- a/internal/processing/fromclientapi.go +++ b/internal/processing/fromclientapi.go @@ -345,10 +345,19 @@ func (p *processor) processDeleteAccountFromClientAPI(ctx context.Context, clien } 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 + report, ok := clientMsg.GTSModel.(*gtsmodel.Report) + if !ok { + return errors.New("report was not parseable as *gtsmodel.Report") + } + + // TODO: in a separate PR, also email admin(s) + + if !*report.Forwarded { + // nothing to do, don't federate the report + return nil + } + + return p.federateReport(ctx, report) } // TODO: move all the below functions into federation.Federator @@ -922,3 +931,51 @@ func (p *processor) federateUnblock(ctx context.Context, block *gtsmodel.Block) _, err = p.federator.FederatingActor().Send(ctx, outboxIRI, undo) return err } + +func (p *processor) federateReport(ctx context.Context, report *gtsmodel.Report) error { + if report.TargetAccount == nil { + reportTargetAccount, err := p.db.GetAccountByID(ctx, report.TargetAccountID) + if err != nil { + return fmt.Errorf("federateReport: error getting report target account from database: %w", err) + } + report.TargetAccount = reportTargetAccount + } + + if len(report.StatusIDs) > 0 && len(report.Statuses) == 0 { + statuses, err := p.db.GetStatuses(ctx, report.StatusIDs) + if err != nil { + return fmt.Errorf("federateReport: error getting report statuses from database: %w", err) + } + report.Statuses = statuses + } + + flag, err := p.tc.ReportToASFlag(ctx, report) + if err != nil { + return fmt.Errorf("federateReport: error converting report to AS flag: %w", err) + } + + // add bto so that our federating actor knows where to + // send the Flag; it'll still use a shared inbox if possible + reportTargetURI, err := url.Parse(report.TargetAccount.URI) + if err != nil { + return fmt.Errorf("federateReport: error parsing outboxURI %s: %w", report.TargetAccount.URI, err) + } + bTo := streams.NewActivityStreamsBtoProperty() + bTo.AppendIRI(reportTargetURI) + flag.SetActivityStreamsBto(bTo) + + // deliver the flag using the outbox of the + // instance account to anonymize the report + instanceAccount, err := p.db.GetInstanceAccount(ctx, "") + if err != nil { + return fmt.Errorf("federateReport: error getting instance account: %w", err) + } + + outboxIRI, err := url.Parse(instanceAccount.OutboxURI) + if err != nil { + return fmt.Errorf("federateReport: error parsing outboxURI %s: %w", instanceAccount.OutboxURI, err) + } + + _, err = p.federator.FederatingActor().Send(ctx, outboxIRI, flag) + return err +} diff --git a/internal/processing/report/create.go b/internal/processing/report/create.go index ac4c4390d..cc2f2405c 100644 --- a/internal/processing/report/create.go +++ b/internal/processing/report/create.go @@ -91,6 +91,7 @@ func (p *processor) Create(ctx context.Context, account *gtsmodel.Account, form APActivityType: ap.ActivityFlag, GTSModel: report, OriginAccount: account, + TargetAccount: targetAccount, }) apiReport, err := p.tc.ReportToAPIReport(ctx, report) diff --git a/internal/regexes/regexes.go b/internal/regexes/regexes.go index 06fb92b41..2c72e7daf 100644 --- a/internal/regexes/regexes.go +++ b/internal/regexes/regexes.go @@ -77,40 +77,45 @@ var ( // EmojiFinder extracts emoji strings from a piece of text. EmojiFinder = regexp.MustCompile(emojiFinderString) - // usernameString defines an acceptable username on this instance + // usernameString defines an acceptable username for a new account on this instance usernameString = fmt.Sprintf(`[a-z0-9_]{2,%d}`, maximumUsernameLength) // Username can be used to validate usernames of new signups Username = regexp.MustCompile(fmt.Sprintf(`^%s$`, usernameString)) - userPathString = fmt.Sprintf(`^?/%s/(%s)$`, users, usernameString) + // usernameStringRelaxed is like usernameString, but also allows the '.' character, + // so it can also be used to match the instance account, which will have a username + // like 'example.org', and it has no upper length limit, so will work for long domains. + usernameStringRelaxed = `[a-z0-9_\.]{2,}` + + userPathString = fmt.Sprintf(`^/?%s/(%s)$`, users, usernameStringRelaxed) // UserPath parses a path that validates and captures the username part from eg /users/example_username UserPath = regexp.MustCompile(userPathString) - publicKeyPath = fmt.Sprintf(`^?/%s/(%s)/%s`, users, usernameString, publicKey) + publicKeyPath = fmt.Sprintf(`^/?%s/(%s)/%s`, users, usernameStringRelaxed, publicKey) // PublicKeyPath parses a path that validates and captures the username part from eg /users/example_username/main-key PublicKeyPath = regexp.MustCompile(publicKeyPath) - inboxPath = fmt.Sprintf(`^/?%s/(%s)/%s$`, users, usernameString, inbox) + inboxPath = fmt.Sprintf(`^/?%s/(%s)/%s$`, users, usernameStringRelaxed, inbox) // InboxPath parses a path that validates and captures the username part from eg /users/example_username/inbox InboxPath = regexp.MustCompile(inboxPath) - outboxPath = fmt.Sprintf(`^/?%s/(%s)/%s$`, users, usernameString, outbox) + outboxPath = fmt.Sprintf(`^/?%s/(%s)/%s$`, users, usernameStringRelaxed, outbox) // OutboxPath parses a path that validates and captures the username part from eg /users/example_username/outbox OutboxPath = regexp.MustCompile(outboxPath) - actorPath = fmt.Sprintf(`^?/%s/(%s)$`, actors, usernameString) + actorPath = fmt.Sprintf(`^/?%s/(%s)$`, actors, usernameStringRelaxed) // ActorPath parses a path that validates and captures the username part from eg /actors/example_username ActorPath = regexp.MustCompile(actorPath) - followersPath = fmt.Sprintf(`^/?%s/(%s)/%s$`, users, usernameString, followers) + followersPath = fmt.Sprintf(`^/?%s/(%s)/%s$`, users, usernameStringRelaxed, followers) // FollowersPath parses a path that validates and captures the username part from eg /users/example_username/followers FollowersPath = regexp.MustCompile(followersPath) - followingPath = fmt.Sprintf(`^/?%s/(%s)/%s$`, users, usernameString, following) + followingPath = fmt.Sprintf(`^/?%s/(%s)/%s$`, users, usernameStringRelaxed, following) // FollowingPath parses a path that validates and captures the username part from eg /users/example_username/following FollowingPath = regexp.MustCompile(followingPath) - followPath = fmt.Sprintf(`^/?%s/(%s)/%s/(%s)$`, users, usernameString, follow, ulid) + followPath = fmt.Sprintf(`^/?%s/(%s)/%s/(%s)$`, users, usernameStringRelaxed, follow, ulid) // FollowPath parses a path that validates and captures the username part and the ulid part // from eg /users/example_username/follow/01F7XT5JZW1WMVSW1KADS8PVDH FollowPath = regexp.MustCompile(followPath) @@ -119,22 +124,22 @@ var ( // ULID parses and validate a ULID. ULID = regexp.MustCompile(fmt.Sprintf(`^%s$`, ulid)) - likedPath = fmt.Sprintf(`^/?%s/(%s)/%s$`, users, usernameString, liked) + likedPath = fmt.Sprintf(`^/?%s/(%s)/%s$`, users, usernameStringRelaxed, liked) // LikedPath parses a path that validates and captures the username part from eg /users/example_username/liked LikedPath = regexp.MustCompile(likedPath) - likePath = fmt.Sprintf(`^/?%s/(%s)/%s/(%s)$`, users, usernameString, liked, ulid) + likePath = fmt.Sprintf(`^/?%s/(%s)/%s/(%s)$`, users, usernameStringRelaxed, liked, ulid) // LikePath parses a path that validates and captures the username part and the ulid part // from eg /users/example_username/like/01F7XT5JZW1WMVSW1KADS8PVDH LikePath = regexp.MustCompile(likePath) - statusesPath = fmt.Sprintf(`^/?%s/(%s)/%s/(%s)$`, users, usernameString, statuses, ulid) + statusesPath = fmt.Sprintf(`^/?%s/(%s)/%s/(%s)$`, users, usernameStringRelaxed, statuses, ulid) // StatusesPath parses a path that validates and captures the username part and the ulid part // from eg /users/example_username/statuses/01F7XT5JZW1WMVSW1KADS8PVDH // The regex can be played with here: https://regex101.com/r/G9zuxQ/1 StatusesPath = regexp.MustCompile(statusesPath) - blockPath = fmt.Sprintf(`^/?%s/(%s)/%s/(%s)$`, users, usernameString, blocks, ulid) + blockPath = fmt.Sprintf(`^/?%s/(%s)/%s/(%s)$`, users, usernameStringRelaxed, blocks, ulid) // BlockPath parses a path that validates and captures the username part and the ulid part // from eg /users/example_username/blocks/01F7XT5JZW1WMVSW1KADS8PVDH BlockPath = regexp.MustCompile(blockPath) diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go index 33ed617e0..ec7b09f27 100644 --- a/internal/typeutils/converter.go +++ b/internal/typeutils/converter.go @@ -188,6 +188,8 @@ type TypeConverter interface { // // Appropriate 'next' and 'prev' fields will be created based on the highest and lowest IDs present in the statuses slice. StatusesToASOutboxPage(ctx context.Context, outboxID string, maxID string, minID string, statuses []*gtsmodel.Status) (vocab.ActivityStreamsOrderedCollectionPage, error) + // ReportToASFlag converts a gts model report into an activitystreams FLAG, suitable for federation. + ReportToASFlag(ctx context.Context, r *gtsmodel.Report) (vocab.ActivityStreamsFlag, error) /* INTERNAL (gts) MODEL TO INTERNAL MODEL diff --git a/internal/typeutils/internaltoas.go b/internal/typeutils/internaltoas.go index bf4dc7e18..2ae58b317 100644 --- a/internal/typeutils/internaltoas.go +++ b/internal/typeutils/internaltoas.go @@ -1295,3 +1295,53 @@ func (c *converter) OutboxToASCollection(ctx context.Context, outboxID string) ( return collection, nil } + +func (c *converter) ReportToASFlag(ctx context.Context, r *gtsmodel.Report) (vocab.ActivityStreamsFlag, error) { + flag := streams.NewActivityStreamsFlag() + + flagIDProp := streams.NewJSONLDIdProperty() + idURI, err := url.Parse(r.URI) + if err != nil { + return nil, fmt.Errorf("error parsing url %s: %w", r.URI, err) + } + flagIDProp.SetIRI(idURI) + flag.SetJSONLDId(flagIDProp) + + // for privacy, set the actor as the INSTANCE ACTOR, + // not as the actor who created the report + instanceAccount, err := c.db.GetInstanceAccount(ctx, "") + if err != nil { + return nil, fmt.Errorf("error getting instance account: %w", err) + } + instanceAccountIRI, err := url.Parse(instanceAccount.URI) + if err != nil { + return nil, fmt.Errorf("error parsing url %s: %w", instanceAccount.URI, err) + } + flagActorProp := streams.NewActivityStreamsActorProperty() + flagActorProp.AppendIRI(instanceAccountIRI) + flag.SetActivityStreamsActor(flagActorProp) + + // content should be the comment submitted when the report was created + contentProp := streams.NewActivityStreamsContentProperty() + contentProp.AppendXMLSchemaString(r.Comment) + flag.SetActivityStreamsContent(contentProp) + + // set at least the target account uri as the object of the flag + objectProp := streams.NewActivityStreamsObjectProperty() + targetAccountURI, err := url.Parse(r.TargetAccount.URI) + if err != nil { + return nil, fmt.Errorf("error parsing url %s: %w", r.TargetAccount.URI, err) + } + objectProp.AppendIRI(targetAccountURI) + // also set status URIs if they were provided with the report + for _, s := range r.Statuses { + statusURI, err := url.Parse(s.URI) + if err != nil { + return nil, fmt.Errorf("error parsing url %s: %w", s.URI, err) + } + objectProp.AppendIRI(statusURI) + } + flag.SetActivityStreamsObject(objectProp) + + return flag, nil +} diff --git a/internal/typeutils/internaltoas_test.go b/internal/typeutils/internaltoas_test.go index d9a91b736..0bbb80dac 100644 --- a/internal/typeutils/internaltoas_test.go +++ b/internal/typeutils/internaltoas_test.go @@ -510,6 +510,40 @@ func (suite *InternalToASTestSuite) TestSelfBoostFollowersOnlyToAS() { }`, string(bytes)) } +func (suite *InternalToASTestSuite) TestReportToAS() { + ctx := context.Background() + + testReport := suite.testReports["local_account_2_report_remote_account_1"] + account := suite.testAccounts["local_account_2"] + targetAccount := suite.testAccounts["remote_account_1"] + statuses := []*gtsmodel.Status{suite.testStatuses["remote_account_1_status_1"]} + + testReport.Account = account + testReport.TargetAccount = targetAccount + testReport.Statuses = statuses + + flag, err := suite.typeconverter.ReportToASFlag(ctx, testReport) + suite.NoError(err) + + ser, err := streams.Serialize(flag) + suite.NoError(err) + + bytes, err := json.MarshalIndent(ser, "", " ") + suite.NoError(err) + + suite.Equal(`{ + "@context": "https://www.w3.org/ns/activitystreams", + "actor": "http://localhost:8080/users/localhost:8080", + "content": "dark souls sucks, please yeet this nerd", + "id": "http://localhost:8080/reports/01GP3AWY4CRDVRNZKW0TEAMB5R", + "object": [ + "http://fossbros-anonymous.io/users/foss_satan", + "http://fossbros-anonymous.io/users/foss_satan/statuses/01FVW7JHQFSFK166WWKR8CBA6M" + ], + "type": "Flag" +}`, string(bytes)) +} + func TestInternalToASTestSuite(t *testing.T) { suite.Run(t, new(InternalToASTestSuite)) } diff --git a/testrig/testmodels.go b/testrig/testmodels.go index 77da1bf38..f44dce71c 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -1928,7 +1928,7 @@ func NewTestReports() map[string]*gtsmodel.Report { ID: "01GP3AWY4CRDVRNZKW0TEAMB5R", CreatedAt: TimeMustParse("2022-05-14T12:20:03+02:00"), UpdatedAt: TimeMustParse("2022-05-14T12:20:03+02:00"), - URI: "http://localhost:8080/01GP3AWY4CRDVRNZKW0TEAMB5R", + URI: "http://localhost:8080/reports/01GP3AWY4CRDVRNZKW0TEAMB5R", AccountID: "01F8MH5NBDF2MV7CTC4Q5128HF", TargetAccountID: "01F8MH5ZK5VRH73AKHQM6Y9VNX", Comment: "dark souls sucks, please yeet this nerd",