[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)
This commit is contained in:
tobi 2023-01-27 14:48:11 +01:00 committed by GitHub
parent c59ec6f2a4
commit 3283900b0d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 207 additions and 69 deletions

View file

@ -621,7 +621,7 @@ func ExtractActor(i WithActor) (*url.URL, error) {
return nil, errors.New("no iri found for actor prop") 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) { func ExtractObject(i WithObject) (*url.URL, error) {
objectProp := i.GetActivityStreamsObject() objectProp := i.GetActivityStreamsObject()
if objectProp == nil { if objectProp == nil {

View file

@ -20,7 +20,7 @@ package federatingdb
import ( import (
"context" "context"
"errors" "fmt"
"net/url" "net/url"
"codeberg.org/gruf/go-kv" "codeberg.org/gruf/go-kv"
@ -33,34 +33,27 @@ import (
// //
// The library makes this call only after acquiring a lock first. // 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) { func (f *federatingDB) Get(ctx context.Context, id *url.URL) (value vocab.Type, err error) {
l := log.WithFields(kv.Fields{ l := log.WithFields(kv.Fields{{"id", id}}...)
{"id", id},
}...)
l.Debug("entering Get") l.Debug("entering Get")
if uris.IsUserPath(id) { switch {
case uris.IsUserPath(id):
acct, err := f.db.GetAccountByURI(ctx, id.String()) acct, err := f.db.GetAccountByURI(ctx, id.String())
if err != nil { if err != nil {
return nil, err return nil, err
} }
return f.typeConverter.AccountToAS(ctx, acct) return f.typeConverter.AccountToAS(ctx, acct)
} case uris.IsStatusesPath(id):
if uris.IsStatusesPath(id) {
status, err := f.db.GetStatusByURI(ctx, id.String()) status, err := f.db.GetStatusByURI(ctx, id.String())
if err != nil { if err != nil {
return nil, err return nil, err
} }
return f.typeConverter.StatusToAS(ctx, status) return f.typeConverter.StatusToAS(ctx, status)
} case uris.IsFollowersPath(id):
if uris.IsFollowersPath(id) {
return f.Followers(ctx, id) return f.Followers(ctx, id)
} case uris.IsFollowingPath(id):
if uris.IsFollowingPath(id) {
return f.Following(ctx, 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")
} }

View file

@ -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. // 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) { func (f *federatingDB) getAccountForIRI(ctx context.Context, iri *url.URL) (*gtsmodel.Account, error) {
acct := &gtsmodel.Account{} var (
acct = &gtsmodel.Account{}
err error
)
if uris.IsInboxPath(iri) { switch {
if err := f.db.GetWhere(ctx, []db.Where{{Key: "inbox_uri", Value: iri.String()}}, acct); err != nil { case uris.IsUserPath(iri):
if err == db.ErrNoEntries { if acct, err = f.db.GetAccountByURI(ctx, iri.String()); err != nil {
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 {
if err == db.ErrNoEntries { if err == db.ErrNoEntries {
return nil, fmt.Errorf("no actor found that corresponds to uri %s", iri.String()) 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 nil, fmt.Errorf("db error searching for actor with uri %s", iri.String())
} }
return acct, nil return acct, nil
} case uris.IsInboxPath(iri):
if err = f.db.GetWhere(ctx, []db.Where{{Key: "inbox_uri", Value: iri.String()}}, acct); err != nil {
if uris.IsFollowersPath(iri) { if err == db.ErrNoEntries {
if err := f.db.GetWhere(ctx, []db.Where{{Key: "followers_uri", Value: iri.String()}}, acct); err != nil { 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 { if err == db.ErrNoEntries {
return nil, fmt.Errorf("no actor found that corresponds to followers_uri %s", iri.String()) 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 nil, fmt.Errorf("db error searching for actor with followers_uri %s", iri.String())
} }
return acct, nil return acct, nil
} case uris.IsFollowingPath(iri):
if err = f.db.GetWhere(ctx, []db.Where{{Key: "following_uri", Value: iri.String()}}, acct); err != nil {
if uris.IsFollowingPath(iri) {
if err := f.db.GetWhere(ctx, []db.Where{{Key: "following_uri", Value: iri.String()}}, acct); err != nil {
if err == db.ErrNoEntries { if err == db.ErrNoEntries {
return nil, fmt.Errorf("no actor found that corresponds to following_uri %s", iri.String()) 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 nil, fmt.Errorf("db error searching for actor with following_uri %s", iri.String())
} }
return acct, nil 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. // collectFollows takes a slice of iris and converts them into ActivityStreamsCollection of IRIs.

View file

@ -345,10 +345,19 @@ func (p *processor) processDeleteAccountFromClientAPI(ctx context.Context, clien
} }
func (p *processor) processReportAccountFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { func (p *processor) processReportAccountFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error {
// TODO: in a separate PR, handle side effects of flag/report report, ok := clientMsg.GTSModel.(*gtsmodel.Report)
// 1. email admin(s) if !ok {
// 2. federate report if necessary return errors.New("report was not parseable as *gtsmodel.Report")
return nil }
// 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 // 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) _, err = p.federator.FederatingActor().Send(ctx, outboxIRI, undo)
return err 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
}

View file

@ -91,6 +91,7 @@ func (p *processor) Create(ctx context.Context, account *gtsmodel.Account, form
APActivityType: ap.ActivityFlag, APActivityType: ap.ActivityFlag,
GTSModel: report, GTSModel: report,
OriginAccount: account, OriginAccount: account,
TargetAccount: targetAccount,
}) })
apiReport, err := p.tc.ReportToAPIReport(ctx, report) apiReport, err := p.tc.ReportToAPIReport(ctx, report)

View file

@ -77,40 +77,45 @@ var (
// EmojiFinder extracts emoji strings from a piece of text. // EmojiFinder extracts emoji strings from a piece of text.
EmojiFinder = regexp.MustCompile(emojiFinderString) 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) usernameString = fmt.Sprintf(`[a-z0-9_]{2,%d}`, maximumUsernameLength)
// Username can be used to validate usernames of new signups // Username can be used to validate usernames of new signups
Username = regexp.MustCompile(fmt.Sprintf(`^%s$`, usernameString)) 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 parses a path that validates and captures the username part from eg /users/example_username
UserPath = regexp.MustCompile(userPathString) 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 parses a path that validates and captures the username part from eg /users/example_username/main-key
PublicKeyPath = regexp.MustCompile(publicKeyPath) 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 parses a path that validates and captures the username part from eg /users/example_username/inbox
InboxPath = regexp.MustCompile(inboxPath) 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 parses a path that validates and captures the username part from eg /users/example_username/outbox
OutboxPath = regexp.MustCompile(outboxPath) 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 parses a path that validates and captures the username part from eg /actors/example_username
ActorPath = regexp.MustCompile(actorPath) 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 parses a path that validates and captures the username part from eg /users/example_username/followers
FollowersPath = regexp.MustCompile(followersPath) 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 parses a path that validates and captures the username part from eg /users/example_username/following
FollowingPath = regexp.MustCompile(followingPath) 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 // FollowPath parses a path that validates and captures the username part and the ulid part
// from eg /users/example_username/follow/01F7XT5JZW1WMVSW1KADS8PVDH // from eg /users/example_username/follow/01F7XT5JZW1WMVSW1KADS8PVDH
FollowPath = regexp.MustCompile(followPath) FollowPath = regexp.MustCompile(followPath)
@ -119,22 +124,22 @@ var (
// ULID parses and validate a ULID. // ULID parses and validate a ULID.
ULID = regexp.MustCompile(fmt.Sprintf(`^%s$`, 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 parses a path that validates and captures the username part from eg /users/example_username/liked
LikedPath = regexp.MustCompile(likedPath) 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 // LikePath parses a path that validates and captures the username part and the ulid part
// from eg /users/example_username/like/01F7XT5JZW1WMVSW1KADS8PVDH // from eg /users/example_username/like/01F7XT5JZW1WMVSW1KADS8PVDH
LikePath = regexp.MustCompile(likePath) 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 // StatusesPath parses a path that validates and captures the username part and the ulid part
// from eg /users/example_username/statuses/01F7XT5JZW1WMVSW1KADS8PVDH // from eg /users/example_username/statuses/01F7XT5JZW1WMVSW1KADS8PVDH
// The regex can be played with here: https://regex101.com/r/G9zuxQ/1 // The regex can be played with here: https://regex101.com/r/G9zuxQ/1
StatusesPath = regexp.MustCompile(statusesPath) 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 // BlockPath parses a path that validates and captures the username part and the ulid part
// from eg /users/example_username/blocks/01F7XT5JZW1WMVSW1KADS8PVDH // from eg /users/example_username/blocks/01F7XT5JZW1WMVSW1KADS8PVDH
BlockPath = regexp.MustCompile(blockPath) BlockPath = regexp.MustCompile(blockPath)

View file

@ -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. // 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) 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 INTERNAL (gts) MODEL TO INTERNAL MODEL

View file

@ -1295,3 +1295,53 @@ func (c *converter) OutboxToASCollection(ctx context.Context, outboxID string) (
return collection, nil 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
}

View file

@ -510,6 +510,40 @@ func (suite *InternalToASTestSuite) TestSelfBoostFollowersOnlyToAS() {
}`, string(bytes)) }`, 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) { func TestInternalToASTestSuite(t *testing.T) {
suite.Run(t, new(InternalToASTestSuite)) suite.Run(t, new(InternalToASTestSuite))
} }

View file

@ -1928,7 +1928,7 @@ func NewTestReports() map[string]*gtsmodel.Report {
ID: "01GP3AWY4CRDVRNZKW0TEAMB5R", ID: "01GP3AWY4CRDVRNZKW0TEAMB5R",
CreatedAt: TimeMustParse("2022-05-14T12:20:03+02:00"), CreatedAt: TimeMustParse("2022-05-14T12:20:03+02:00"),
UpdatedAt: 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", AccountID: "01F8MH5NBDF2MV7CTC4Q5128HF",
TargetAccountID: "01F8MH5ZK5VRH73AKHQM6Y9VNX", TargetAccountID: "01F8MH5ZK5VRH73AKHQM6Y9VNX",
Comment: "dark souls sucks, please yeet this nerd", Comment: "dark souls sucks, please yeet this nerd",