diff options
author | 2024-07-26 12:04:28 +0200 | |
---|---|---|
committer | 2024-07-26 12:04:28 +0200 | |
commit | 8ab2b19a946251f258446d22f420d401f61d22f6 (patch) | |
tree | 39fb674f135fd1cfcf4de5b319913f0d0c17d11a /internal | |
parent | [docs] Add separate migration section + instructions for moving to GtS and no... (diff) | |
download | gotosocial-8ab2b19a946251f258446d22f420d401f61d22f6.tar.xz |
[feature] Federate interaction policies + Accepts; enforce policies (#3138)
* [feature] Federate interaction policies + Accepts; enforce policies
* use Acceptable type
* fix index
* remove appendIRIStrs
* add GetAccept federatingdb function
* lock on object IRI
Diffstat (limited to 'internal')
35 files changed, 2941 insertions, 223 deletions
diff --git a/internal/ap/ap_test.go b/internal/ap/ap_test.go index 0a9f66ca6..f982e4443 100644 --- a/internal/ap/ap_test.go +++ b/internal/ap/ap_test.go @@ -28,6 +28,7 @@ import ( "github.com/superseriousbusiness/activity/streams" "github.com/superseriousbusiness/activity/streams/vocab" "github.com/superseriousbusiness/gotosocial/internal/ap" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/testrig" ) @@ -103,6 +104,66 @@ func noteWithMentions1() vocab.ActivityStreamsNote { note.SetActivityStreamsContent(content) + policy := streams.NewGoToSocialInteractionPolicy() + + // Set canLike. + canLike := streams.NewGoToSocialCanLike() + + // Anyone can like. + canLikeAlwaysProp := streams.NewGoToSocialAlwaysProperty() + canLikeAlwaysProp.AppendIRI(testrig.URLMustParse(pub.PublicActivityPubIRI)) + canLike.SetGoToSocialAlways(canLikeAlwaysProp) + + // Empty approvalRequired. + canLikeApprovalRequiredProp := streams.NewGoToSocialApprovalRequiredProperty() + canLike.SetGoToSocialApprovalRequired(canLikeApprovalRequiredProp) + + // Set canLike on the policy. + canLikeProp := streams.NewGoToSocialCanLikeProperty() + canLikeProp.AppendGoToSocialCanLike(canLike) + policy.SetGoToSocialCanLike(canLikeProp) + + // Build canReply. + canReply := streams.NewGoToSocialCanReply() + + // Anyone can reply. + canReplyAlwaysProp := streams.NewGoToSocialAlwaysProperty() + canReplyAlwaysProp.AppendIRI(testrig.URLMustParse(pub.PublicActivityPubIRI)) + canReply.SetGoToSocialAlways(canReplyAlwaysProp) + + // Set empty approvalRequired. + canReplyApprovalRequiredProp := streams.NewGoToSocialApprovalRequiredProperty() + canReply.SetGoToSocialApprovalRequired(canReplyApprovalRequiredProp) + + // Set canReply on the policy. + canReplyProp := streams.NewGoToSocialCanReplyProperty() + canReplyProp.AppendGoToSocialCanReply(canReply) + policy.SetGoToSocialCanReply(canReplyProp) + + // Build canAnnounce. + canAnnounce := streams.NewGoToSocialCanAnnounce() + + // Only f0x and dumpsterqueer can announce. + canAnnounceAlwaysProp := streams.NewGoToSocialAlwaysProperty() + canAnnounceAlwaysProp.AppendIRI(testrig.URLMustParse("https://gts.superseriousbusiness.org/users/dumpsterqueer")) + canAnnounceAlwaysProp.AppendIRI(testrig.URLMustParse("https://gts.superseriousbusiness.org/users/f0x")) + canAnnounce.SetGoToSocialAlways(canAnnounceAlwaysProp) + + // Public requires approval to announce. + canAnnounceApprovalRequiredProp := streams.NewGoToSocialApprovalRequiredProperty() + canAnnounceApprovalRequiredProp.AppendIRI(testrig.URLMustParse(pub.PublicActivityPubIRI)) + canAnnounce.SetGoToSocialApprovalRequired(canAnnounceApprovalRequiredProp) + + // Set canAnnounce on the policy. + canAnnounceProp := streams.NewGoToSocialCanAnnounceProperty() + canAnnounceProp.AppendGoToSocialCanAnnounce(canAnnounce) + policy.SetGoToSocialCanAnnounce(canAnnounceProp) + + // Set the policy on the note. + policyProp := streams.NewGoToSocialInteractionPolicyProperty() + policyProp.AppendGoToSocialInteractionPolicy(policy) + note.SetGoToSocialInteractionPolicy(policyProp) + return note } @@ -296,6 +357,7 @@ type APTestSuite struct { addressable3 ap.Addressable addressable4 vocab.ActivityStreamsAnnounce addressable5 ap.Addressable + testAccounts map[string]*gtsmodel.Account } func (suite *APTestSuite) jsonToType(rawJson string) (vocab.Type, map[string]interface{}) { @@ -336,4 +398,5 @@ func (suite *APTestSuite) SetupTest() { suite.addressable3 = addressable3() suite.addressable4 = addressable4() suite.addressable5 = addressable5() + suite.testAccounts = testrig.NewTestAccounts() } diff --git a/internal/ap/extract.go b/internal/ap/extract.go index e0c90c5d7..ce1e2d421 100644 --- a/internal/ap/extract.go +++ b/internal/ap/extract.go @@ -1057,6 +1057,137 @@ func ExtractVisibility(addressable Addressable, actorFollowersURI string) (gtsmo return visibility, nil } +// ExtractInteractionPolicy extracts a *gtsmodel.InteractionPolicy +// from the given Statusable created by by the given *gtsmodel.Account. +// +// Will be nil (default policy) for Statusables that have no policy +// set on them, or have a null policy. In such a case, the caller +// should assume the default policy for the status's visibility level. +func ExtractInteractionPolicy( + statusable Statusable, + owner *gtsmodel.Account, +) *gtsmodel.InteractionPolicy { + policyProp := statusable.GetGoToSocialInteractionPolicy() + if policyProp == nil || policyProp.Len() != 1 { + return nil + } + + policyPropIter := policyProp.At(0) + if !policyPropIter.IsGoToSocialInteractionPolicy() { + return nil + } + + policy := policyPropIter.Get() + if policy == nil { + return nil + } + + return >smodel.InteractionPolicy{ + CanLike: extractCanLike(policy.GetGoToSocialCanLike(), owner), + CanReply: extractCanReply(policy.GetGoToSocialCanReply(), owner), + CanAnnounce: extractCanAnnounce(policy.GetGoToSocialCanAnnounce(), owner), + } +} + +func extractCanLike( + prop vocab.GoToSocialCanLikeProperty, + owner *gtsmodel.Account, +) gtsmodel.PolicyRules { + if prop == nil || prop.Len() != 1 { + return gtsmodel.PolicyRules{} + } + + propIter := prop.At(0) + if !propIter.IsGoToSocialCanLike() { + return gtsmodel.PolicyRules{} + } + + withRules := propIter.Get() + if withRules == nil { + return gtsmodel.PolicyRules{} + } + + return gtsmodel.PolicyRules{ + Always: extractPolicyValues(withRules.GetGoToSocialAlways(), owner), + WithApproval: extractPolicyValues(withRules.GetGoToSocialApprovalRequired(), owner), + } +} + +func extractCanReply( + prop vocab.GoToSocialCanReplyProperty, + owner *gtsmodel.Account, +) gtsmodel.PolicyRules { + if prop == nil || prop.Len() != 1 { + return gtsmodel.PolicyRules{} + } + + propIter := prop.At(0) + if !propIter.IsGoToSocialCanReply() { + return gtsmodel.PolicyRules{} + } + + withRules := propIter.Get() + if withRules == nil { + return gtsmodel.PolicyRules{} + } + + return gtsmodel.PolicyRules{ + Always: extractPolicyValues(withRules.GetGoToSocialAlways(), owner), + WithApproval: extractPolicyValues(withRules.GetGoToSocialApprovalRequired(), owner), + } +} + +func extractCanAnnounce( + prop vocab.GoToSocialCanAnnounceProperty, + owner *gtsmodel.Account, +) gtsmodel.PolicyRules { + if prop == nil || prop.Len() != 1 { + return gtsmodel.PolicyRules{} + } + + propIter := prop.At(0) + if !propIter.IsGoToSocialCanAnnounce() { + return gtsmodel.PolicyRules{} + } + + withRules := propIter.Get() + if withRules == nil { + return gtsmodel.PolicyRules{} + } + + return gtsmodel.PolicyRules{ + Always: extractPolicyValues(withRules.GetGoToSocialAlways(), owner), + WithApproval: extractPolicyValues(withRules.GetGoToSocialApprovalRequired(), owner), + } +} + +func extractPolicyValues[T WithIRI]( + prop Property[T], + owner *gtsmodel.Account, +) gtsmodel.PolicyValues { + iris := getIRIs(prop) + PolicyValues := make(gtsmodel.PolicyValues, 0, len(iris)) + + for _, iri := range iris { + switch iriStr := iri.String(); iriStr { + case pub.PublicActivityPubIRI: + PolicyValues = append(PolicyValues, gtsmodel.PolicyValuePublic) + case owner.FollowersURI: + PolicyValues = append(PolicyValues, gtsmodel.PolicyValueFollowers) + case owner.FollowingURI: + PolicyValues = append(PolicyValues, gtsmodel.PolicyValueFollowers) + case owner.URI: + PolicyValues = append(PolicyValues, gtsmodel.PolicyValueAuthor) + default: + if iri.Scheme == "http" || iri.Scheme == "https" { + PolicyValues = append(PolicyValues, gtsmodel.PolicyValue(iriStr)) + } + } + } + + return PolicyValues +} + // ExtractSensitive extracts whether or not an item should // be marked as sensitive according to its ActivityStreams // sensitive property. diff --git a/internal/ap/extractpolicy_test.go b/internal/ap/extractpolicy_test.go new file mode 100644 index 000000000..3d5e75c41 --- /dev/null +++ b/internal/ap/extractpolicy_test.go @@ -0,0 +1,137 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package ap_test + +import ( + "bytes" + "context" + "io" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/ap" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +type ExtractPolicyTestSuite struct { + APTestSuite +} + +func (suite *ExtractPolicyTestSuite) TestExtractPolicy() { + rawNote := `{ + "@context": [ + "https://gotosocial.org/ns", + "https://www.w3.org/ns/activitystreams" + ], + "content": "hey @f0x and @dumpsterqueer", + "contentMap": { + "en": "hey @f0x and @dumpsterqueer", + "fr": "bonjour @f0x et @dumpsterqueer" + }, + "interactionPolicy": { + "canLike": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + }, + "canReply": { + "always": [ + "http://localhost:8080/users/the_mighty_zork", + "http://localhost:8080/users/the_mighty_zork/followers", + "https://gts.superseriousbusiness.org/users/dumpsterqueer", + "https://gts.superseriousbusiness.org/users/f0x" + ], + "approvalRequired": [ + "https://www.w3.org/ns/activitystreams#Public" + ] + }, + "canAnnounce": { + "always": [ + "http://localhost:8080/users/the_mighty_zork" + ], + "approvalRequired": [ + "https://www.w3.org/ns/activitystreams#Public" + ] + } + }, + "tag": [ + { + "href": "https://gts.superseriousbusiness.org/users/dumpsterqueer", + "name": "@dumpsterqueer@superseriousbusiness.org", + "type": "Mention" + }, + { + "href": "https://gts.superseriousbusiness.org/users/f0x", + "name": "@f0x@superseriousbusiness.org", + "type": "Mention" + } + ], + "type": "Note" +}` + + statusable, err := ap.ResolveStatusable( + context.Background(), + io.NopCloser( + bytes.NewBufferString(rawNote), + ), + ) + if err != nil { + suite.FailNow(err.Error()) + } + + policy := ap.ExtractInteractionPolicy( + statusable, + // Zork didn't actually create + // this status but nevermind. + suite.testAccounts["local_account_1"], + ) + + expectedPolicy := >smodel.InteractionPolicy{ + CanLike: gtsmodel.PolicyRules{ + Always: gtsmodel.PolicyValues{ + gtsmodel.PolicyValuePublic, + }, + WithApproval: gtsmodel.PolicyValues{}, + }, + CanReply: gtsmodel.PolicyRules{ + Always: gtsmodel.PolicyValues{ + gtsmodel.PolicyValueAuthor, + gtsmodel.PolicyValueFollowers, + "https://gts.superseriousbusiness.org/users/dumpsterqueer", + "https://gts.superseriousbusiness.org/users/f0x", + }, + WithApproval: gtsmodel.PolicyValues{ + gtsmodel.PolicyValuePublic, + }, + }, + CanAnnounce: gtsmodel.PolicyRules{ + Always: gtsmodel.PolicyValues{ + gtsmodel.PolicyValueAuthor, + }, + WithApproval: gtsmodel.PolicyValues{ + gtsmodel.PolicyValuePublic, + }, + }, + } + suite.EqualValues(expectedPolicy, policy) +} + +func TestExtractPolicyTestSuite(t *testing.T) { + suite.Run(t, &ExtractPolicyTestSuite{}) +} diff --git a/internal/ap/interfaces.go b/internal/ap/interfaces.go index 8f2e17c09..a721fa997 100644 --- a/internal/ap/interfaces.go +++ b/internal/ap/interfaces.go @@ -124,6 +124,24 @@ func ToPollOptionable(t vocab.Type) (PollOptionable, bool) { return note, true } +// IsAccept returns whether AS vocab type name +// is something that can be cast to Accept. +func IsAcceptable(typeName string) bool { + return typeName == ActivityAccept +} + +// ToAcceptable safely tries to cast vocab.Type as vocab.ActivityStreamsAccept. +// +// TODO: Add additional "Accept" types here, eg., "ApproveReply" from +// https://codeberg.org/fediverse/fep/src/branch/main/fep/5624/fep-5624.md +func ToAcceptable(t vocab.Type) (vocab.ActivityStreamsAccept, bool) { + acceptable, ok := t.(vocab.ActivityStreamsAccept) + if !ok || !IsAcceptable(t.GetTypeName()) { + return nil, false + } + return acceptable, true +} + // Activityable represents the minimum activitypub interface for representing an 'activity'. // (see: IsActivityable() for types implementing this, though you MUST make sure to check // the typeName as this bare interface may be implementable by non-Activityable types). @@ -188,6 +206,8 @@ type Statusable interface { WithAttachment WithTag WithReplies + WithInteractionPolicy + WithApprovedBy } // Pollable represents the minimum activitypub interface for representing a 'poll' (it's a subset of a status). @@ -217,6 +237,12 @@ type PollOptionable interface { WithAttributedTo } +// Acceptable represents the minimum activitypub +// interface for representing an Accept. +type Acceptable interface { + Activityable +} + // Attachmentable represents the minimum activitypub interface for representing a 'mediaAttachment'. (see: IsAttachmentable). // This interface is fulfilled by: Audio, Document, Image, Video type Attachmentable interface { @@ -657,3 +683,21 @@ type WithVotersCount interface { GetTootVotersCount() vocab.TootVotersCountProperty SetTootVotersCount(vocab.TootVotersCountProperty) } + +// WithReplies represents an object with GoToSocialInteractionPolicy. +type WithInteractionPolicy interface { + GetGoToSocialInteractionPolicy() vocab.GoToSocialInteractionPolicyProperty + SetGoToSocialInteractionPolicy(vocab.GoToSocialInteractionPolicyProperty) +} + +// WithPolicyRules represents an activity with always and approvalRequired properties. +type WithPolicyRules interface { + GetGoToSocialAlways() vocab.GoToSocialAlwaysProperty + GetGoToSocialApprovalRequired() vocab.GoToSocialApprovalRequiredProperty +} + +// WithApprovedBy represents a Statusable with the approvedBy property. +type WithApprovedBy interface { + GetGoToSocialApprovedBy() vocab.GoToSocialApprovedByProperty + SetGoToSocialApprovedBy(vocab.GoToSocialApprovedByProperty) +} diff --git a/internal/ap/normalize.go b/internal/ap/normalize.go index bef6d93b0..30d8515a5 100644 --- a/internal/ap/normalize.go +++ b/internal/ap/normalize.go @@ -575,6 +575,107 @@ func NormalizeOutgoingContentProp(item WithContent, rawJSON map[string]interface } } +// NormalizeOutgoingInteractionPolicyProp replaces single-entry interactionPolicy values +// with single-entry arrays, for better compatibility with other AP implementations. +// +// Ie: +// +// "interactionPolicy": { +// "canAnnounce": { +// "always": "https://www.w3.org/ns/activitystreams#Public", +// "approvalRequired": [] +// }, +// "canLike": { +// "always": "https://www.w3.org/ns/activitystreams#Public", +// "approvalRequired": [] +// }, +// "canReply": { +// "always": "https://www.w3.org/ns/activitystreams#Public", +// "approvalRequired": [] +// } +// } +// +// becomes: +// +// "interactionPolicy": { +// "canAnnounce": { +// "always": [ +// "https://www.w3.org/ns/activitystreams#Public" +// ], +// "approvalRequired": [] +// }, +// "canLike": { +// "always": [ +// "https://www.w3.org/ns/activitystreams#Public" +// ], +// "approvalRequired": [] +// }, +// "canReply": { +// "always": [ +// "https://www.w3.org/ns/activitystreams#Public" +// ], +// "approvalRequired": [] +// } +// } +// +// Noop for items with no attachments, or with attachments that are already a slice. +func NormalizeOutgoingInteractionPolicyProp(item WithInteractionPolicy, rawJSON map[string]interface{}) { + policy, ok := rawJSON["interactionPolicy"] + if !ok { + // No 'interactionPolicy', + // nothing to change. + return + } + + policyMap, ok := policy.(map[string]interface{}) + if !ok { + // Malformed 'interactionPolicy', + // nothing to change. + return + } + + for _, rulesKey := range []string{ + "canLike", + "canReply", + "canAnnounce", + } { + // Either "canAnnounce", + // "canLike", or "canApprove" + rulesVal, ok := policyMap[rulesKey] + if !ok { + // Not set. + return + } + + rulesValMap, ok := rulesVal.(map[string]interface{}) + if !ok { + // Malformed or not + // present skip. + return + } + + for _, PolicyValuesKey := range []string{ + "always", + "approvalRequired", + } { + PolicyValuesVal, ok := rulesValMap[PolicyValuesKey] + if !ok { + // Not set. + continue + } + + if _, ok := PolicyValuesVal.([]interface{}); ok { + // Already slice, + // nothing to change. + continue + } + + // Coerce single-object to slice. + rulesValMap[PolicyValuesKey] = []interface{}{PolicyValuesVal} + } + } +} + // NormalizeOutgoingObjectProp normalizes each Object entry in the rawJSON of the given // item by calling custom serialization / normalization functions on them in turn. // diff --git a/internal/ap/properties.go b/internal/ap/properties.go index 1bd8c303e..38e58ebc0 100644 --- a/internal/ap/properties.go +++ b/internal/ap/properties.go @@ -520,6 +520,27 @@ func SetManuallyApprovesFollowers(with WithManuallyApprovesFollowers, manuallyAp mafProp.Set(manuallyApprovesFollowers) } +// GetApprovedBy returns the URL contained in +// the ApprovedBy property of 'with', if set. +func GetApprovedBy(with WithApprovedBy) *url.URL { + mafProp := with.GetGoToSocialApprovedBy() + if mafProp == nil || !mafProp.IsIRI() { + return nil + } + return mafProp.Get() +} + +// SetApprovedBy sets the given url +// on the ApprovedBy property of 'with'. +func SetApprovedBy(with WithApprovedBy, approvedBy *url.URL) { + abProp := with.GetGoToSocialApprovedBy() + if abProp == nil { + abProp = streams.NewGoToSocialApprovedByProperty() + with.SetGoToSocialApprovedBy(abProp) + } + abProp.Set(approvedBy) +} + // extractIRIs extracts just the AP IRIs from an iterable // property that may contain types (with IRIs) or just IRIs. // diff --git a/internal/ap/resolve.go b/internal/ap/resolve.go index b2e866b6f..76a8809c3 100644 --- a/internal/ap/resolve.go +++ b/internal/ap/resolve.go @@ -37,6 +37,8 @@ func ResolveIncomingActivity(r *http.Request) (pub.Activity, bool, gtserror.With // Get "raw" map // destination. raw := getMap() + // Release. + defer putMap(raw) // Decode data as JSON into 'raw' map // and get the resolved AS vocab.Type. @@ -79,9 +81,6 @@ func ResolveIncomingActivity(r *http.Request) (pub.Activity, bool, gtserror.With // (see: https://github.com/superseriousbusiness/gotosocial/issues/1661) NormalizeIncomingActivity(activity, raw) - // Release. - putMap(raw) - return activity, true, nil } @@ -93,6 +92,8 @@ func ResolveStatusable(ctx context.Context, body io.ReadCloser) (Statusable, err // Get "raw" map // destination. raw := getMap() + // Release. + defer putMap(raw) // Decode data as JSON into 'raw' map // and get the resolved AS vocab.Type. @@ -121,9 +122,6 @@ func ResolveStatusable(ctx context.Context, body io.ReadCloser) (Statusable, err NormalizeIncomingSummary(statusable, raw) NormalizeIncomingName(statusable, raw) - // Release. - putMap(raw) - return statusable, nil } @@ -135,6 +133,8 @@ func ResolveAccountable(ctx context.Context, body io.ReadCloser) (Accountable, e // Get "raw" map // destination. raw := getMap() + // Release. + defer putMap(raw) // Decode data as JSON into 'raw' map // and get the resolved AS vocab.Type. @@ -153,9 +153,6 @@ func ResolveAccountable(ctx context.Context, body io.ReadCloser) (Accountable, e NormalizeIncomingSummary(accountable, raw) - // Release. - putMap(raw) - return accountable, nil } @@ -165,6 +162,8 @@ func ResolveCollection(ctx context.Context, body io.ReadCloser) (CollectionItera // Get "raw" map // destination. raw := getMap() + // Release. + defer putMap(raw) // Decode data as JSON into 'raw' map // and get the resolved AS vocab.Type. @@ -174,9 +173,6 @@ func ResolveCollection(ctx context.Context, body io.ReadCloser) (CollectionItera return nil, gtserror.SetWrongType(err) } - // Release. - putMap(raw) - // Cast as as Collection-like. return ToCollectionIterator(t) } @@ -187,6 +183,8 @@ func ResolveCollectionPage(ctx context.Context, body io.ReadCloser) (CollectionP // Get "raw" map // destination. raw := getMap() + // Release. + defer putMap(raw) // Decode data as JSON into 'raw' map // and get the resolved AS vocab.Type. @@ -196,13 +194,40 @@ func ResolveCollectionPage(ctx context.Context, body io.ReadCloser) (CollectionP return nil, gtserror.SetWrongType(err) } - // Release. - putMap(raw) - // Cast as as CollectionPage-like. return ToCollectionPageIterator(t) } +// ResolveAcceptable tries to resolve the given reader +// into an ActivityStreams Acceptable representation. +func ResolveAcceptable( + ctx context.Context, + body io.ReadCloser, +) (Acceptable, error) { + // Get "raw" map + // destination. + raw := getMap() + // Release. + defer putMap(raw) + + // Decode data as JSON into 'raw' map + // and get the resolved AS vocab.Type. + // (this handles close of given body). + t, err := decodeType(ctx, body, raw) + if err != nil { + return nil, gtserror.SetWrongType(err) + } + + // Attempt to cast as acceptable. + acceptable, ok := ToAcceptable(t) + if !ok { + err := gtserror.Newf("cannot resolve vocab type %T as acceptable", t) + return nil, gtserror.SetWrongType(err) + } + + return acceptable, nil +} + // emptydest is an empty JSON decode // destination useful for "noop" decodes // to check underlying reader is empty. diff --git a/internal/ap/serialize.go b/internal/ap/serialize.go index b13ebb340..3e5a92d79 100644 --- a/internal/ap/serialize.go +++ b/internal/ap/serialize.go @@ -37,7 +37,7 @@ import ( // - OrderedCollection: 'orderedItems' property will always be made into an array. // - OrderedCollectionPage: 'orderedItems' property will always be made into an array. // - Any Accountable type: 'attachment' property will always be made into an array. -// - Any Statusable type: 'attachment' property will always be made into an array; 'content' and 'contentMap' will be normalized. +// - Any Statusable type: 'attachment' property will always be made into an array; 'content', 'contentMap', and 'interactionPolicy' will be normalized. // - Any Activityable type: any 'object's set on an activity will be custom serialized as above. func Serialize(t vocab.Type) (m map[string]interface{}, e error) { switch tn := t.GetTypeName(); { @@ -153,6 +153,7 @@ func serializeStatusable(t vocab.Type, includeContext bool) (map[string]interfac NormalizeOutgoingAttachmentProp(statusable, data) NormalizeOutgoingContentProp(statusable, data) + NormalizeOutgoingInteractionPolicyProp(statusable, data) return data, nil } diff --git a/internal/api/activitypub/users/acceptget.go b/internal/api/activitypub/users/acceptget.go new file mode 100644 index 000000000..c2b438330 --- /dev/null +++ b/internal/api/activitypub/users/acceptget.go @@ -0,0 +1,55 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package users + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +// AcceptGETHandler serves an interactionApproval as an ActivityStreams Accept. +func (m *Module) AcceptGETHandler(c *gin.Context) { + username, errWithCode := apiutil.ParseUsername(c.Param(apiutil.UsernameKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + acceptID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + contentType, err := apiutil.NegotiateAccept(c, apiutil.ActivityPubHeaders...) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + resp, errWithCode := m.processor.Fedi().AcceptGet(c.Request.Context(), username, acceptID) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSONType(c, http.StatusOK, contentType, resp) +} diff --git a/internal/api/activitypub/users/user.go b/internal/api/activitypub/users/user.go index 5e3d5d187..5122e610e 100644 --- a/internal/api/activitypub/users/user.go +++ b/internal/api/activitypub/users/user.go @@ -21,6 +21,7 @@ import ( "net/http" "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/processing" "github.com/superseriousbusiness/gotosocial/internal/uris" ) @@ -55,6 +56,8 @@ const ( StatusPath = BasePath + "/" + uris.StatusesPath + "/:" + StatusIDKey // StatusRepliesPath is for serving the replies collection of a status. StatusRepliesPath = StatusPath + "/replies" + // AcceptPath is for serving accepts of a status. + AcceptPath = BasePath + "/" + uris.AcceptsPath + "/:" + apiutil.IDKey ) type Module struct { @@ -76,4 +79,5 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H attachHandler(http.MethodGet, StatusPath, m.StatusGETHandler) attachHandler(http.MethodGet, StatusRepliesPath, m.StatusRepliesGETHandler) attachHandler(http.MethodGet, OutboxPath, m.OutboxGETHandler) + attachHandler(http.MethodGet, AcceptPath, m.AcceptGETHandler) } diff --git a/internal/db/bundb/migrations/20240716151327_interaction_policy.go b/internal/db/bundb/migrations/20240716151327_interaction_policy.go new file mode 100644 index 000000000..fb0d1d752 --- /dev/null +++ b/internal/db/bundb/migrations/20240716151327_interaction_policy.go @@ -0,0 +1,71 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package migrations + +import ( + "context" + + gtsmodel "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/uptrace/bun" +) + +func init() { + up := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + if _, err := tx. + NewCreateTable(). + Model(>smodel.InteractionApproval{}). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + + if _, err := tx. + NewCreateIndex(). + Table("interaction_approvals"). + Index("interaction_approvals_account_id_idx"). + Column("account_id"). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + + if _, err := tx. + NewCreateIndex(). + Table("interaction_approvals"). + Index("interaction_approvals_interacting_account_id_idx"). + Column("interacting_account_id"). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + + return nil + }) + } + + down := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + return nil + }) + } + + if err := Migrations.Register(up, down); err != nil { + panic(err) + } +} diff --git a/internal/federation/dereferencing/status_permitted.go b/internal/federation/dereferencing/status_permitted.go index 5c16f9f15..97cd61e93 100644 --- a/internal/federation/dereferencing/status_permitted.go +++ b/internal/federation/dereferencing/status_permitted.go @@ -19,10 +19,13 @@ package dereferencing import ( "context" + "net/url" + "github.com/superseriousbusiness/gotosocial/internal/ap" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/util" ) // isPermittedStatus returns whether the given status @@ -147,12 +150,67 @@ func (d *Dereferencer) isPermittedReply( return onFalse() } - // TODO in next PR: check conditional / - // with approval and deref Accept. - if !replyable.Permitted() { + if replyable.Permitted() && + !replyable.MatchedOnCollection() { + // Replier is permitted to do this + // interaction, and didn't match on + // a collection so we don't need to + // do further checking. + return true, nil + } + + // Replier is permitted to do this + // interaction pending approval, or + // permitted but matched on a collection. + // + // Check if we can dereference + // an Accept that grants approval. + + if status.ApprovedByURI == "" { + // Status doesn't claim to be approved. + // + // For replies to local statuses that's + // fine, we can put it in the DB pending + // approval, and continue processing it. + // + // If permission was granted based on a match + // with a followers or following collection, + // we can mark it as PreApproved so the processor + // sends an accept out for it immediately. + // + // For replies to remote statuses, though + // we should be polite and just drop it. + if inReplyTo.IsLocal() { + status.PendingApproval = util.Ptr(true) + status.PreApproved = replyable.MatchedOnCollection() + return true, nil + } + + return onFalse() + } + + // Status claims to be approved, check + // this by dereferencing the Accept and + // inspecting the return value. + if err := d.validateApprovedBy( + ctx, + requestUser, + status.ApprovedByURI, + status.URI, + inReplyTo.AccountURI, + ); err != nil { + // Error dereferencing means we couldn't + // get the Accept right now or it wasn't + // valid, so we shouldn't store this status. + // + // Do log the error though as it may be + // interesting for admins to see. + log.Info(ctx, "rejecting reply with undereferenceable ApprovedByURI: %v", err) return onFalse() } + // Status has been approved. + status.PendingApproval = util.Ptr(false) return true, nil } @@ -206,11 +264,203 @@ func (d *Dereferencer) isPermittedBoost( return onFalse() } - // TODO in next PR: check conditional / - // with approval and deref Accept. - if !boostable.Permitted() { + if boostable.Permitted() && + !boostable.MatchedOnCollection() { + // Booster is permitted to do this + // interaction, and didn't match on + // a collection so we don't need to + // do further checking. + return true, nil + } + + // Booster is permitted to do this + // interaction pending approval, or + // permitted but matched on a collection. + // + // Check if we can dereference + // an Accept that grants approval. + + if status.ApprovedByURI == "" { + // Status doesn't claim to be approved. + // + // For boosts of local statuses that's + // fine, we can put it in the DB pending + // approval, and continue processing it. + // + // If permission was granted based on a match + // with a followers or following collection, + // we can mark it as PreApproved so the processor + // sends an accept out for it immediately. + // + // For boosts of remote statuses, though + // we should be polite and just drop it. + if boostOf.IsLocal() { + status.PendingApproval = util.Ptr(true) + status.PreApproved = boostable.MatchedOnCollection() + return true, nil + } + return onFalse() } + // Boost claims to be approved, check + // this by dereferencing the Accept and + // inspecting the return value. + if err := d.validateApprovedBy( + ctx, + requestUser, + status.ApprovedByURI, + status.URI, + boostOf.AccountURI, + ); err != nil { + // Error dereferencing means we couldn't + // get the Accept right now or it wasn't + // valid, so we shouldn't store this status. + // + // Do log the error though as it may be + // interesting for admins to see. + log.Info(ctx, "rejecting boost with undereferenceable ApprovedByURI: %v", err) + return onFalse() + } + + // Status has been approved. + status.PendingApproval = util.Ptr(false) return true, nil } + +// validateApprovedBy dereferences the activitystreams Accept at +// the specified IRI, and checks the Accept for validity against +// the provided expectedObject and expectedActor. +// +// Will return either nil if everything looked OK, or an error if +// something went wrong during deref, or if the dereffed Accept +// did not meet expectations. +func (d *Dereferencer) validateApprovedBy( + ctx context.Context, + requestUser string, + approvedByURIStr string, // Eg., "https://example.org/users/someone/accepts/01J2736AWWJ3411CPR833F6D03" + expectedObject string, // Eg., "https://some.instance.example.org/users/someone_else/statuses/01J27414TWV9F7DC39FN8ABB5R" + expectedActor string, // Eg., "https://example.org/users/someone" +) error { + approvedByURI, err := url.Parse(approvedByURIStr) + if err != nil { + err := gtserror.Newf("error parsing approvedByURI: %w", err) + return err + } + + // Don't make calls to the remote if it's blocked. + if blocked, err := d.state.DB.IsDomainBlocked(ctx, approvedByURI.Host); blocked || err != nil { + err := gtserror.Newf("domain %s is blocked", approvedByURI.Host) + return err + } + + transport, err := d.transportController.NewTransportForUsername(ctx, requestUser) + if err != nil { + err := gtserror.Newf("error creating transport: %w", err) + return err + } + + // Make the call to resolve into an Acceptable. + rsp, err := transport.Dereference(ctx, approvedByURI) + if err != nil { + err := gtserror.Newf("error dereferencing %s: %w", approvedByURIStr, err) + return err + } + + acceptable, err := ap.ResolveAcceptable(ctx, rsp.Body) + + // Tidy up rsp body. + _ = rsp.Body.Close() + + if err != nil { + err := gtserror.Newf("error resolving Accept %s: %w", approvedByURIStr, err) + return err + } + + // Extract the URI/ID of the Accept. + acceptURI := ap.GetJSONLDId(acceptable) + acceptURIStr := acceptURI.String() + + // Check whether input URI and final returned URI + // have changed (i.e. we followed some redirects). + rspURL := rsp.Request.URL + rspURLStr := rspURL.String() + if rspURLStr != approvedByURIStr { + // Final URI was different from approvedByURIStr. + // + // Make sure it's at least on the same host as + // what we expected (ie., we weren't redirected + // across domains), and make sure it's the same + // as the ID of the Accept we were returned. + if rspURL.Host != approvedByURI.Host { + err := gtserror.Newf( + "final dereference host %s did not match approvedByURI host %s", + rspURL.Host, approvedByURI.Host, + ) + return err + } + + if acceptURIStr != rspURLStr { + err := gtserror.Newf( + "final dereference uri %s did not match returned Accept ID/URI %s", + rspURLStr, acceptURIStr, + ) + return err + } + } + + // Ensure the Accept URI has the same host + // as the Accept Actor, so we know we're + // not dealing with someone on a different + // domain just pretending to be the Actor. + actorIRIs := ap.GetActorIRIs(acceptable) + if len(actorIRIs) != 1 { + err := gtserror.New("resolved Accept actor(s) length was not 1") + return gtserror.SetMalformed(err) + } + + actorIRI := actorIRIs[0] + actorStr := actorIRI.String() + + if actorIRI.Host != acceptURI.Host { + err := gtserror.Newf( + "Accept Actor %s was not the same host as Accept %s", + actorStr, acceptURIStr, + ) + return err + } + + // Ensure the Accept Actor is who we expect + // it to be, and not someone else trying to + // do an Accept for an interaction with a + // statusable they don't own. + if actorStr != expectedActor { + err := gtserror.Newf( + "Accept Actor %s was not the same as expected actor %s", + actorStr, expectedActor, + ) + return err + } + + // Ensure the Accept Object is what we expect + // it to be, ie., it's Accepting the interaction + // we need it to Accept, and not something else. + objectIRIs := ap.GetObjectIRIs(acceptable) + if len(objectIRIs) != 1 { + err := gtserror.New("resolved Accept object(s) length was not 1") + return err + } + + objectIRI := objectIRIs[0] + objectStr := objectIRI.String() + + if objectStr != expectedObject { + err := gtserror.Newf( + "resolved Accept Object uri %s was not the same as expected object %s", + objectStr, expectedObject, + ) + return err + } + + return nil +} diff --git a/internal/federation/federatingdb/accept.go b/internal/federation/federatingdb/accept.go index e26e5955b..60b9cfe58 100644 --- a/internal/federation/federatingdb/accept.go +++ b/internal/federation/federatingdb/accept.go @@ -20,16 +20,31 @@ package federatingdb import ( "context" "errors" - "fmt" + "net/url" "codeberg.org/gruf/go-logger/v2/level" "github.com/superseriousbusiness/activity/streams/vocab" "github.com/superseriousbusiness/gotosocial/internal/ap" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/messages" "github.com/superseriousbusiness/gotosocial/internal/uris" + "github.com/superseriousbusiness/gotosocial/internal/util" ) +func (f *federatingDB) GetAccept( + ctx context.Context, + acceptIRI *url.URL, +) (vocab.ActivityStreamsAccept, error) { + approval, err := f.state.DB.GetInteractionApprovalByURI(ctx, acceptIRI.String()) + if err != nil { + return nil, err + } + return f.converter.InteractionApprovalToASAccept(ctx, approval) +} + func (f *federatingDB) Accept(ctx context.Context, accept vocab.ActivityStreamsAccept) error { if log.Level() >= level.DEBUG { i, err := marshalItem(accept) @@ -55,100 +70,382 @@ func (f *federatingDB) Accept(ctx context.Context, accept vocab.ActivityStreamsA return nil } - // Iterate all provided objects in the activity. - for _, object := range ap.ExtractObjects(accept) { + activityID := ap.GetJSONLDId(accept) + if activityID == nil { + // We need an ID. + const text = "Accept had no id property" + return gtserror.NewErrorBadRequest(errors.New(text), text) + } - // Check and handle any vocab.Type objects. - if objType := object.GetType(); objType != nil { - switch objType.GetTypeName() { //nolint:gocritic + // Iterate all provided objects in the activity, + // handling the ones we know how to handle. + for _, object := range ap.ExtractObjects(accept) { + if asType := object.GetType(); asType != nil { + // Check and handle any + // vocab.Type objects. + // nolint:gocritic + switch asType.GetTypeName() { + // ACCEPT FOLLOW case ap.ActivityFollow: - // Cast the vocab.Type object to known AS type. - asFollow := objType.(vocab.ActivityStreamsFollow) - - // convert the follow to something we can understand - gtsFollow, err := f.converter.ASFollowToFollow(ctx, asFollow) - if err != nil { - return fmt.Errorf("ACCEPT: error converting asfollow to gtsfollow: %s", err) + if err := f.acceptFollowType( + ctx, + asType, + receivingAcct, + requestingAcct, + ); err != nil { + return err } + } - // Make sure the creator of the original follow - // is the same as whatever inbox this landed in. - if gtsFollow.AccountID != receivingAcct.ID { - return errors.New("ACCEPT: follow account and inbox account were not the same") - } + } else if object.IsIRI() { + // Check and handle any + // IRI type objects. + switch objIRI := object.GetIRI(); { - // Make sure the target of the original follow - // is the same as the account making the request. - if gtsFollow.TargetAccountID != requestingAcct.ID { - return errors.New("ACCEPT: follow target account and requesting account were not the same") + // ACCEPT FOLLOW + case uris.IsFollowPath(objIRI): + if err := f.acceptFollowIRI( + ctx, + objIRI.String(), + receivingAcct, + requestingAcct, + ); err != nil { + return err } - follow, err := f.state.DB.AcceptFollowRequest(ctx, gtsFollow.AccountID, gtsFollow.TargetAccountID) - if err != nil { + // ACCEPT STATUS (reply/boost) + case uris.IsStatusesPath(objIRI): + if err := f.acceptStatusIRI( + ctx, + activityID.String(), + objIRI.String(), + receivingAcct, + requestingAcct, + ); err != nil { return err } - f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{ - APObjectType: ap.ActivityFollow, - APActivityType: ap.ActivityAccept, - GTSModel: follow, - Receiving: receivingAcct, - Requesting: requestingAcct, - }) + // ACCEPT LIKE + case uris.IsLikePath(objIRI): + if err := f.acceptLikeIRI( + ctx, + activityID.String(), + objIRI.String(), + receivingAcct, + requestingAcct, + ); err != nil { + return err + } } - - continue } + } - // Check and handle any - // IRI type objects. - if object.IsIRI() { + return nil +} - // Extract IRI from object. - iri := object.GetIRI() - if !uris.IsFollowPath(iri) { - continue - } +func (f *federatingDB) acceptFollowType( + ctx context.Context, + asType vocab.Type, + receivingAcct *gtsmodel.Account, + requestingAcct *gtsmodel.Account, +) error { + // Cast the vocab.Type object to known AS type. + asFollow := asType.(vocab.ActivityStreamsFollow) - // Serialize IRI. - iriStr := iri.String() + // Reconstruct the follow. + follow, err := f.converter.ASFollowToFollow(ctx, asFollow) + if err != nil { + err := gtserror.Newf("error converting Follow to *gtsmodel.Follow: %w", err) + return gtserror.NewErrorInternalError(err) + } - // ACCEPT FOLLOW - followReq, err := f.state.DB.GetFollowRequestByURI(ctx, iriStr) - if err != nil { - return fmt.Errorf("ACCEPT: couldn't get follow request with id %s from the database: %s", iriStr, err) - } + // Lock on the Follow URI + // as we may be updating it. + unlock := f.state.FedLocks.Lock(follow.URI) + defer unlock() - // Make sure the creator of the original follow - // is the same as whatever inbox this landed in. - if followReq.AccountID != receivingAcct.ID { - return errors.New("ACCEPT: follow account and inbox account were not the same") - } + // Make sure the creator of the original follow + // is the same as whatever inbox this landed in. + if follow.AccountID != receivingAcct.ID { + const text = "Follow account and inbox account were not the same" + return gtserror.NewErrorUnprocessableEntity(errors.New(text), text) + } - // Make sure the target of the original follow - // is the same as the account making the request. - if followReq.TargetAccountID != requestingAcct.ID { - return errors.New("ACCEPT: follow target account and requesting account were not the same") - } + // Make sure the target of the original follow + // is the same as the account making the request. + if follow.TargetAccountID != requestingAcct.ID { + const text = "Follow target account and requesting account were not the same" + return gtserror.NewErrorForbidden(errors.New(text), text) + } - follow, err := f.state.DB.AcceptFollowRequest(ctx, followReq.AccountID, followReq.TargetAccountID) - if err != nil { - return err - } + // Accept and get the populated follow back. + follow, err = f.state.DB.AcceptFollowRequest( + ctx, + follow.AccountID, + follow.TargetAccountID, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error accepting follow request: %w", err) + return gtserror.NewErrorInternalError(err) + } + + if follow == nil { + // There was no follow request + // to accept, just return 202. + return nil + } - f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{ - APObjectType: ap.ActivityFollow, - APActivityType: ap.ActivityAccept, - GTSModel: follow, - Receiving: receivingAcct, - Requesting: requestingAcct, - }) + // Send the accepted follow through + // the processor to do side effects. + f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{ + APObjectType: ap.ActivityFollow, + APActivityType: ap.ActivityAccept, + GTSModel: follow, + Receiving: receivingAcct, + Requesting: requestingAcct, + }) - continue - } + return nil +} + +func (f *federatingDB) acceptFollowIRI( + ctx context.Context, + objectIRI string, + receivingAcct *gtsmodel.Account, + requestingAcct *gtsmodel.Account, +) error { + // Lock on this potential Follow + // URI as we may be updating it. + unlock := f.state.FedLocks.Lock(objectIRI) + defer unlock() + // Get the follow req from the db. + followReq, err := f.state.DB.GetFollowRequestByURI(ctx, objectIRI) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting follow request: %w", err) + return gtserror.NewErrorInternalError(err) + } + + if followReq == nil { + // We didn't have a follow request + // with this URI, so nothing to do. + // Just return. + return nil + } + + // Make sure the creator of the original follow + // is the same as whatever inbox this landed in. + if followReq.AccountID != receivingAcct.ID { + const text = "Follow account and inbox account were not the same" + return gtserror.NewErrorUnprocessableEntity(errors.New(text), text) + } + + // Make sure the target of the original follow + // is the same as the account making the request. + if followReq.TargetAccountID != requestingAcct.ID { + const text = "Follow target account and requesting account were not the same" + return gtserror.NewErrorForbidden(errors.New(text), text) + } + + // Accept and get the populated follow back. + follow, err := f.state.DB.AcceptFollowRequest( + ctx, + followReq.AccountID, + followReq.TargetAccountID, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error accepting follow request: %w", err) + return gtserror.NewErrorInternalError(err) + } + + if follow == nil { + // There was no follow request + // to accept, just return 202. + return nil } + // Send the accepted follow through + // the processor to do side effects. + f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{ + APObjectType: ap.ActivityFollow, + APActivityType: ap.ActivityAccept, + GTSModel: follow, + Receiving: receivingAcct, + Requesting: requestingAcct, + }) + + return nil +} + +func (f *federatingDB) acceptStatusIRI( + ctx context.Context, + activityID string, + objectIRI string, + receivingAcct *gtsmodel.Account, + requestingAcct *gtsmodel.Account, +) error { + // Lock on this potential status + // URI as we may be updating it. + unlock := f.state.FedLocks.Lock(objectIRI) + defer unlock() + + // Get the status from the db. + status, err := f.state.DB.GetStatusByURI(ctx, objectIRI) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting status: %w", err) + return gtserror.NewErrorInternalError(err) + } + + if status == nil { + // We didn't have a status with + // this URI, so nothing to do. + // Just return. + return nil + } + + if !status.IsLocal() { + // We don't process Accepts of statuses + // that weren't created on our instance. + // Just return. + return nil + } + + if util.PtrOrValue(status.PendingApproval, false) { + // Status doesn't need approval or it's + // already been approved by an Accept. + // Just return. + return nil + } + + // Make sure the creator of the original status + // is the same as the inbox processing the Accept; + // this also ensures the status is local. + if status.AccountID != receivingAcct.ID { + const text = "status author account and inbox account were not the same" + return gtserror.NewErrorUnprocessableEntity(errors.New(text), text) + } + + // Make sure the target of the interaction (reply/boost) + // is the same as the account doing the Accept. + if status.BoostOfAccountID != requestingAcct.ID && + status.InReplyToAccountID != requestingAcct.ID { + const text = "status reply to or boost of account and requesting account were not the same" + return gtserror.NewErrorForbidden(errors.New(text), text) + } + + // Mark the status as approved by this Accept URI. + status.PendingApproval = util.Ptr(false) + status.ApprovedByURI = activityID + if err := f.state.DB.UpdateStatus( + ctx, + status, + "pending_approval", + "approved_by_uri", + ); err != nil { + err := gtserror.Newf("db error accepting status: %w", err) + return gtserror.NewErrorInternalError(err) + } + + var apObjectType string + if status.InReplyToID != "" { + // Accepting a Reply. + apObjectType = ap.ObjectNote + } else { + // Accepting an Announce. + apObjectType = ap.ActivityAnnounce + } + + // Send the now-approved status through to the + // fedi worker again to process side effects. + f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{ + APObjectType: apObjectType, + APActivityType: ap.ActivityAccept, + GTSModel: status, + Receiving: receivingAcct, + Requesting: requestingAcct, + }) + + return nil +} + +func (f *federatingDB) acceptLikeIRI( + ctx context.Context, + activityID string, + objectIRI string, + receivingAcct *gtsmodel.Account, + requestingAcct *gtsmodel.Account, +) error { + // Lock on this potential Like + // URI as we may be updating it. + unlock := f.state.FedLocks.Lock(objectIRI) + defer unlock() + + // Get the fave from the db. + fave, err := f.state.DB.GetStatusFaveByURI(ctx, objectIRI) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting fave: %w", err) + return gtserror.NewErrorInternalError(err) + } + + if fave == nil { + // We didn't have a fave with + // this URI, so nothing to do. + // Just return. + return nil + } + + if !fave.Account.IsLocal() { + // We don't process Accepts of Likes + // that weren't created on our instance. + // Just return. + return nil + } + + if !util.PtrOrValue(fave.PendingApproval, false) { + // Like doesn't need approval or it's + // already been approved by an Accept. + // Just return. + return nil + } + + // Make sure the creator of the original Like + // is the same as the inbox processing the Accept; + // this also ensures the Like is local. + if fave.AccountID != receivingAcct.ID { + const text = "fave creator account and inbox account were not the same" + return gtserror.NewErrorUnprocessableEntity(errors.New(text), text) + } + + // Make sure the target of the Like is the + // same as the account doing the Accept. + if fave.TargetAccountID != requestingAcct.ID { + const text = "status fave target account and requesting account were not the same" + return gtserror.NewErrorForbidden(errors.New(text), text) + } + + // Mark the fave as approved by this Accept URI. + fave.PendingApproval = util.Ptr(false) + fave.ApprovedByURI = activityID + if err := f.state.DB.UpdateStatusFave( + ctx, + fave, + "pending_approval", + "approved_by_uri", + ); err != nil { + err := gtserror.Newf("db error accepting status: %w", err) + return gtserror.NewErrorInternalError(err) + } + + // Send the now-approved fave through to the + // fedi worker again to process side effects. + f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{ + APObjectType: ap.ActivityLike, + APActivityType: ap.ActivityAccept, + GTSModel: fave, + Receiving: receivingAcct, + Requesting: requestingAcct, + }) + return nil } diff --git a/internal/federation/federatingdb/db.go b/internal/federation/federatingdb/db.go index 12bd5a376..3388d7a03 100644 --- a/internal/federation/federatingdb/db.go +++ b/internal/federation/federatingdb/db.go @@ -19,6 +19,7 @@ package federatingdb import ( "context" + "net/url" "github.com/superseriousbusiness/activity/pub" "github.com/superseriousbusiness/activity/streams/vocab" @@ -43,6 +44,12 @@ type DB interface { Reject(ctx context.Context, reject vocab.ActivityStreamsReject) error Announce(ctx context.Context, announce vocab.ActivityStreamsAnnounce) error Move(ctx context.Context, move vocab.ActivityStreamsMove) error + + /* + Extra/convenience functionality. + */ + + GetAccept(ctx context.Context, acceptIRI *url.URL) (vocab.ActivityStreamsAccept, error) } // FederatingDB uses the given state interface diff --git a/internal/federation/federatingdb/get.go b/internal/federation/federatingdb/get.go index eba58853f..5dcebb877 100644 --- a/internal/federation/federatingdb/get.go +++ b/internal/federation/federatingdb/get.go @@ -37,22 +37,30 @@ func (f *federatingDB) Get(ctx context.Context, id *url.URL) (value vocab.Type, l.Debug("entering Get") switch { + case uris.IsUserPath(id): acct, err := f.state.DB.GetAccountByURI(ctx, id.String()) if err != nil { return nil, err } return f.converter.AccountToAS(ctx, acct) + case uris.IsStatusesPath(id): status, err := f.state.DB.GetStatusByURI(ctx, id.String()) if err != nil { return nil, err } return f.converter.StatusToAS(ctx, status) + case uris.IsFollowersPath(id): return f.Followers(ctx, id) + case uris.IsFollowingPath(id): return f.Following(ctx, id) + + case uris.IsAcceptsPath(id): + return f.GetAccept(ctx, id) + default: return nil, fmt.Errorf("federatingDB: could not Get %s", id.String()) } diff --git a/internal/gtsmodel/status.go b/internal/gtsmodel/status.go index 221663ccd..5f50fb046 100644 --- a/internal/gtsmodel/status.go +++ b/internal/gtsmodel/status.go @@ -68,6 +68,7 @@ type Status struct { Federated *bool `bun:",notnull"` // This status will be federated beyond the local timeline(s) InteractionPolicy *InteractionPolicy `bun:""` // InteractionPolicy for this status. If null then the default InteractionPolicy should be assumed for this status's Visibility. Always null for boost wrappers. PendingApproval *bool `bun:",nullzero,notnull,default:false"` // If true then status is a reply or boost wrapper that must be Approved by the reply-ee or boost-ee before being fully distributed. + PreApproved bool `bun:"-"` // If true, then status is a reply to or boost wrapper of a status on our instance, has permission to do the interaction, and an Accept should be sent out for it immediately. Field not stored in the DB. ApprovedByURI string `bun:",nullzero"` // URI of an Accept Activity that approves the Announce or Create Activity that this status was/will be attached to. } diff --git a/internal/gtsmodel/statusfave.go b/internal/gtsmodel/statusfave.go index 644b3ca63..9d6c6335b 100644 --- a/internal/gtsmodel/statusfave.go +++ b/internal/gtsmodel/statusfave.go @@ -32,5 +32,6 @@ type StatusFave struct { Status *Status `bun:"-"` // the faved status URI string `bun:",nullzero,notnull,unique"` // ActivityPub URI of this fave PendingApproval *bool `bun:",nullzero,notnull,default:false"` // If true then Like must be Approved by the like-ee before being fully distributed. + PreApproved bool `bun:"-"` // If true, then fave targets a status on our instance, has permission to do the interaction, and an Accept should be sent out for it immediately. Field not stored in the DB. ApprovedByURI string `bun:",nullzero"` // URI of an Accept Activity that approves this Like. } diff --git a/internal/processing/fedi/accept.go b/internal/processing/fedi/accept.go new file mode 100644 index 000000000..72d810f94 --- /dev/null +++ b/internal/processing/fedi/accept.go @@ -0,0 +1,84 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package fedi + +import ( + "context" + "errors" + + "github.com/superseriousbusiness/gotosocial/internal/ap" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +// AcceptGet handles the getting of a fedi/activitypub +// representation of a local interaction approval. +// +// It performs appropriate authentication before +// returning a JSON serializable interface. +func (p *Processor) AcceptGet( + ctx context.Context, + requestedUser string, + approvalID string, +) (interface{}, gtserror.WithCode) { + // Authenticate incoming request, getting related accounts. + auth, errWithCode := p.authenticate(ctx, requestedUser) + if errWithCode != nil { + return nil, errWithCode + } + + if auth.handshakingURI != nil { + // We're currently handshaking, which means + // we don't know this account yet. This should + // be a very rare race condition. + err := gtserror.Newf("network race handshaking %s", auth.handshakingURI) + return nil, gtserror.NewErrorInternalError(err) + } + + receivingAcct := auth.receivingAcct + + approval, err := p.state.DB.GetInteractionApprovalByID(ctx, approvalID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting approval %s: %w", approvalID, err) + return nil, gtserror.NewErrorInternalError(err) + } + + if approval.AccountID != receivingAcct.ID { + const text = "approval does not belong to receiving account" + return nil, gtserror.NewErrorNotFound(errors.New(text)) + } + + if approval == nil { + err := gtserror.Newf("approval %s not found", approvalID) + return nil, gtserror.NewErrorNotFound(err) + } + + accept, err := p.converter.InteractionApprovalToASAccept(ctx, approval) + if err != nil { + err := gtserror.Newf("error converting approval: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + data, err := ap.Serialize(accept) + if err != nil { + err := gtserror.Newf("error serializing accept: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return data, nil +} diff --git a/internal/processing/processor.go b/internal/processing/processor.go index 5afcf0721..0afe8356b 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -177,8 +177,7 @@ func NewProcessor( visFilter *visibility.Filter, intFilter *interaction.Filter, ) *Processor { - var parseMentionFunc = GetParseMentionFunc(state, federator) - + parseMentionFunc := GetParseMentionFunc(state, federator) processor := &Processor{ converter: converter, oauthServer: oauthServer, diff --git a/internal/processing/status/boost.go b/internal/processing/status/boost.go index d6a0c2457..1b6e8bd47 100644 --- a/internal/processing/status/boost.go +++ b/internal/processing/status/boost.go @@ -104,9 +104,18 @@ func (p *Processor) BoostCreate( // We're permitted to do this, but since // we matched due to presence in a followers // or following collection, we should mark - // as pending approval and wait for an accept. + // as pending approval and wait until we can + // prove it's been Accepted by the target. pendingApproval = true + if *target.Local { + // If the target is local we don't need + // to wait for an Accept from remote, + // we can just preapprove it and have + // the processor create the Accept. + boost.PreApproved = true + } + case policyResult.Permitted(): // We're permitted to do this // based on another kind of match. diff --git a/internal/processing/status/create.go b/internal/processing/status/create.go index 10e19ac43..11dece87d 100644 --- a/internal/processing/status/create.go +++ b/internal/processing/status/create.go @@ -221,9 +221,18 @@ func (p *Processor) processInReplyTo(ctx context.Context, requester *gtsmodel.Ac // We're permitted to do this, but since // we matched due to presence in a followers // or following collection, we should mark - // as pending approval and wait for an accept. + // as pending approval and wait until we can + // prove it's been Accepted by the target. pendingApproval = true + if *inReplyTo.Local { + // If the target is local we don't need + // to wait for an Accept from remote, + // we can just preapprove it and have + // the processor create the Accept. + status.PreApproved = true + } + case policyResult.Permitted(): // We're permitted to do this // based on another kind of match. diff --git a/internal/processing/status/fave.go b/internal/processing/status/fave.go index 0f5a72b7d..497c4d465 100644 --- a/internal/processing/status/fave.go +++ b/internal/processing/status/fave.go @@ -103,8 +103,13 @@ func (p *Processor) FaveCreate( return nil, gtserror.NewErrorForbidden(err, errText) } - // Derive pendingApproval status. - var pendingApproval bool + // Derive pendingApproval + // and preapproved status. + var ( + pendingApproval bool + preApproved bool + ) + switch { case policyResult.WithApproval(): // We're allowed to do @@ -115,9 +120,18 @@ func (p *Processor) FaveCreate( // We're permitted to do this, but since // we matched due to presence in a followers // or following collection, we should mark - // as pending approval and wait for an accept. + // as pending approval and wait until we can + // prove it's been Accepted by the target. pendingApproval = true + if *status.Local { + // If the target is local we don't need + // to wait for an Accept from remote, + // we can just preapprove it and have + // the processor create the Accept. + preApproved = true + } + case policyResult.Permitted(): // We're permitted to do this // based on another kind of match. @@ -138,6 +152,7 @@ func (p *Processor) FaveCreate( StatusID: status.ID, Status: status, URI: uris.GenerateURIForLike(requester.Username, faveID), + PreApproved: preApproved, PendingApproval: &pendingApproval, } diff --git a/internal/processing/workers/federate.go b/internal/processing/workers/federate.go index 3538c9958..d71bb0a83 100644 --- a/internal/processing/workers/federate.go +++ b/internal/processing/workers/federate.go @@ -23,12 +23,14 @@ import ( "github.com/superseriousbusiness/activity/pub" "github.com/superseriousbusiness/activity/streams" + "github.com/superseriousbusiness/activity/streams/vocab" "github.com/superseriousbusiness/gotosocial/internal/ap" "github.com/superseriousbusiness/gotosocial/internal/federation" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/internal/util" ) // federate wraps functions for federating @@ -135,6 +137,12 @@ func (f *federate) DeleteAccount(ctx context.Context, account *gtsmodel.Account) return nil } +// CreateStatus sends the given status out to relevant +// recipients with the Outbox of the status creator. +// +// If the status is pending approval, then it will be +// sent **ONLY** to the inbox of the account it replies to, +// ignoring shared inboxes. func (f *federate) CreateStatus(ctx context.Context, status *gtsmodel.Status) error { // Do nothing if the status // shouldn't be federated. @@ -153,18 +161,32 @@ func (f *federate) CreateStatus(ctx context.Context, status *gtsmodel.Status) er return gtserror.Newf("error populating status: %w", err) } - // Parse the outbox URI of the status author. - outboxIRI, err := parseURI(status.Account.OutboxURI) - if err != nil { - return err - } - // Convert status to AS Statusable implementing type. statusable, err := f.converter.StatusToAS(ctx, status) if err != nil { return gtserror.Newf("error converting status to Statusable: %w", err) } + // If status is pending approval, + // it must be a reply. Deliver it + // **ONLY** to the account it replies + // to, on behalf of the replier. + if util.PtrOrValue(status.PendingApproval, false) { + return f.deliverToInboxOnly( + ctx, + status.Account, + status.InReplyToAccount, + // Status has to be wrapped in Create activity. + typeutils.WrapStatusableInCreate(statusable, false), + ) + } + + // Parse the outbox URI of the status author. + outboxIRI, err := parseURI(status.Account.OutboxURI) + if err != nil { + return err + } + // Send a Create activity with Statusable via the Actor's outbox. create := typeutils.WrapStatusableInCreate(statusable, false) if _, err := f.FederatingActor().Send(ctx, outboxIRI, create); err != nil { @@ -672,6 +694,12 @@ func (f *federate) RejectFollow(ctx context.Context, follow *gtsmodel.Follow) er return nil } +// Like sends the given fave out to relevant +// recipients with the Outbox of the status creator. +// +// If the fave is pending approval, then it will be +// sent **ONLY** to the inbox of the account it faves, +// ignoring shared inboxes. func (f *federate) Like(ctx context.Context, fave *gtsmodel.StatusFave) error { // Populate model. if err := f.state.DB.PopulateStatusFave(ctx, fave); err != nil { @@ -684,18 +712,30 @@ func (f *federate) Like(ctx context.Context, fave *gtsmodel.StatusFave) error { return nil } - // Parse relevant URI(s). - outboxIRI, err := parseURI(fave.Account.OutboxURI) - if err != nil { - return err - } - // Create the ActivityStreams Like. like, err := f.converter.FaveToAS(ctx, fave) if err != nil { return gtserror.Newf("error converting fave to AS Like: %w", err) } + // If fave is pending approval, + // deliver it **ONLY** to the account + // it faves, on behalf of the faver. + if util.PtrOrValue(fave.PendingApproval, false) { + return f.deliverToInboxOnly( + ctx, + fave.Account, + fave.TargetAccount, + like, + ) + } + + // Parse relevant URI(s). + outboxIRI, err := parseURI(fave.Account.OutboxURI) + if err != nil { + return err + } + // Send the Like via the Actor's outbox. if _, err := f.FederatingActor().Send( ctx, outboxIRI, like, @@ -709,6 +749,12 @@ func (f *federate) Like(ctx context.Context, fave *gtsmodel.StatusFave) error { return nil } +// Announce sends the given boost out to relevant +// recipients with the Outbox of the status creator. +// +// If the boost is pending approval, then it will be +// sent **ONLY** to the inbox of the account it boosts, +// ignoring shared inboxes. func (f *federate) Announce(ctx context.Context, boost *gtsmodel.Status) error { // Populate model. if err := f.state.DB.PopulateStatus(ctx, boost); err != nil { @@ -721,12 +767,6 @@ func (f *federate) Announce(ctx context.Context, boost *gtsmodel.Status) error { return nil } - // Parse relevant URI(s). - outboxIRI, err := parseURI(boost.Account.OutboxURI) - if err != nil { - return err - } - // Create the ActivityStreams Announce. announce, err := f.converter.BoostToAS( ctx, @@ -738,6 +778,24 @@ func (f *federate) Announce(ctx context.Context, boost *gtsmodel.Status) error { return gtserror.Newf("error converting boost to AS: %w", err) } + // If announce is pending approval, + // deliver it **ONLY** to the account + // it boosts, on behalf of the booster. + if util.PtrOrValue(boost.PendingApproval, false) { + return f.deliverToInboxOnly( + ctx, + boost.Account, + boost.BoostOfAccount, + announce, + ) + } + + // Parse relevant URI(s). + outboxIRI, err := parseURI(boost.Account.OutboxURI) + if err != nil { + return err + } + // Send the Announce via the Actor's outbox. if _, err := f.FederatingActor().Send( ctx, outboxIRI, announce, @@ -751,6 +809,57 @@ func (f *federate) Announce(ctx context.Context, boost *gtsmodel.Status) error { return nil } +// deliverToInboxOnly delivers the given Activity +// *only* to the inbox of targetAcct, on behalf of +// sendingAcct, regardless of the `to` and `cc` values +// set on the activity. This should be used specifically +// for sending "pending approval" activities. +func (f *federate) deliverToInboxOnly( + ctx context.Context, + sendingAcct *gtsmodel.Account, + targetAcct *gtsmodel.Account, + t vocab.Type, +) error { + if targetAcct.IsLocal() { + // If this is a local target, + // they've already received it. + return nil + } + + toInbox, err := url.Parse(targetAcct.InboxURI) + if err != nil { + return gtserror.Newf( + "error parsing target inbox uri: %w", + err, + ) + } + + tsport, err := f.TransportController().NewTransportForUsername( + ctx, + sendingAcct.Username, + ) + if err != nil { + return gtserror.Newf( + "error getting transport to deliver activity %T to target inbox %s: %w", + t, targetAcct.InboxURI, err, + ) + } + + m, err := ap.Serialize(t) + if err != nil { + return err + } + + if err := tsport.Deliver(ctx, m, toInbox); err != nil { + return gtserror.Newf( + "error delivering activity %T to target inbox %s: %w", + t, targetAcct.InboxURI, err, + ) + } + + return nil +} + func (f *federate) UpdateAccount(ctx context.Context, account *gtsmodel.Account) error { // Populate model. if err := f.state.DB.PopulateAccount(ctx, account); err != nil { @@ -1015,3 +1124,75 @@ func (f *federate) MoveAccount(ctx context.Context, account *gtsmodel.Account) e return nil } + +func (f *federate) AcceptInteraction( + ctx context.Context, + approval *gtsmodel.InteractionApproval, +) error { + // Populate model. + if err := f.state.DB.PopulateInteractionApproval(ctx, approval); err != nil { + return gtserror.Newf("error populating approval: %w", err) + } + + // Bail if interacting account is ours: + // we've already accepted internally and + // shouldn't send an Accept to ourselves. + if approval.InteractingAccount.IsLocal() { + return nil + } + + // Bail if account isn't ours: + // we can't Accept on another + // instance's behalf. (This + // should never happen but...) + if approval.Account.IsRemote() { + return nil + } + + // Parse relevant URI(s). + outboxIRI, err := parseURI(approval.Account.OutboxURI) + if err != nil { + return err + } + + acceptingAcctIRI, err := parseURI(approval.Account.URI) + if err != nil { + return err + } + + interactingAcctURI, err := parseURI(approval.InteractingAccount.URI) + if err != nil { + return err + } + + interactionURI, err := parseURI(approval.InteractionURI) + if err != nil { + return err + } + + // Create a new Accept. + accept := streams.NewActivityStreamsAccept() + + // Set interacted-with account + // as Actor of the Accept. + ap.AppendActorIRIs(accept, acceptingAcctIRI) + + // Set the interacted-with object + // as Object of the Accept. + ap.AppendObjectIRIs(accept, interactionURI) + + // Address the Accept To the interacting acct. + ap.AppendTo(accept, interactingAcctURI) + + // Send the Accept via the Actor's outbox. + if _, err := f.FederatingActor().Send( + ctx, outboxIRI, accept, + ); err != nil { + return gtserror.Newf( + "error sending activity %T for %v via outbox %s: %w", + accept, approval.InteractionType, outboxIRI, err, + ) + } + + return nil +} diff --git a/internal/processing/workers/fromclientapi.go b/internal/processing/workers/fromclientapi.go index d5d4265e1..7f1b5780c 100644 --- a/internal/processing/workers/fromclientapi.go +++ b/internal/processing/workers/fromclientapi.go @@ -135,6 +135,18 @@ func (p *Processor) ProcessFromClientAPI(ctx context.Context, cMsg *messages.Fro // ACCEPT USER (ie., new user+account sign-up) case ap.ObjectProfile: return p.clientAPI.AcceptUser(ctx, cMsg) + + // ACCEPT NOTE/STATUS (ie., accept a reply) + case ap.ObjectNote: + return p.clientAPI.AcceptReply(ctx, cMsg) + + // ACCEPT LIKE + case ap.ActivityLike: + return p.clientAPI.AcceptLike(ctx, cMsg) + + // ACCEPT BOOST + case ap.ActivityAnnounce: + return p.clientAPI.AcceptAnnounce(ctx, cMsg) } // REJECT SOMETHING @@ -236,6 +248,61 @@ func (p *clientAPI) CreateStatus(ctx context.Context, cMsg *messages.FromClientA return gtserror.Newf("%T not parseable as *gtsmodel.Status", cMsg.GTSModel) } + // If pending approval is true then status must + // reply to a status (either one of ours or a + // remote) that requires approval for the reply. + pendingApproval := util.PtrOrValue( + status.PendingApproval, + false, + ) + + switch { + case pendingApproval && !status.PreApproved: + // If approval is required and status isn't + // preapproved, then send out the Create to + // only the replied-to account (if it's remote), + // and/or notify the account that's being + // interacted with (if it's local): they can + // approve or deny the interaction later. + + // Notify *local* account of pending reply. + if err := p.surface.notifyPendingReply(ctx, status); err != nil { + log.Errorf(ctx, "error notifying pending reply: %v", err) + } + + // Send Create to *remote* account inbox ONLY. + if err := p.federate.CreateStatus(ctx, status); err != nil { + log.Errorf(ctx, "error federating pending reply: %v", err) + } + + // Return early. + return nil + + case pendingApproval && status.PreApproved: + // If approval is required and status is + // preapproved, that means this is a reply + // to one of our statuses with permission + // that matched on a following/followers + // collection. Do the Accept immediately and + // then process everything else as normal, + // sending out the Create with the approval + // URI attached. + + // Put approval in the database and + // update the status with approvedBy URI. + approval, err := p.utils.approveReply(ctx, status) + if err != nil { + return gtserror.Newf("error pre-approving reply: %w", err) + } + + // Send out the approval as Accept. + if err := p.federate.AcceptInteraction(ctx, approval); err != nil { + return gtserror.Newf("error federating pre-approval of reply: %w", err) + } + + // Don't return, just continue as normal. + } + // Update stats for the actor account. if err := p.utils.incrementStatusesCount(ctx, cMsg.Origin, status); err != nil { log.Errorf(ctx, "error updating account stats: %v", err) @@ -362,6 +429,61 @@ func (p *clientAPI) CreateLike(ctx context.Context, cMsg *messages.FromClientAPI return gtserror.Newf("error populating status fave: %w", err) } + // If pending approval is true then fave must + // target a status (either one of ours or a + // remote) that requires approval for the fave. + pendingApproval := util.PtrOrValue( + fave.PendingApproval, + false, + ) + + switch { + case pendingApproval && !fave.PreApproved: + // If approval is required and fave isn't + // preapproved, then send out the Like to + // only the faved account (if it's remote), + // and/or notify the account that's being + // interacted with (if it's local): they can + // approve or deny the interaction later. + + // Notify *local* account of pending reply. + if err := p.surface.notifyPendingFave(ctx, fave); err != nil { + log.Errorf(ctx, "error notifying pending fave: %v", err) + } + + // Send Like to *remote* account inbox ONLY. + if err := p.federate.Like(ctx, fave); err != nil { + log.Errorf(ctx, "error federating pending Like: %v", err) + } + + // Return early. + return nil + + case pendingApproval && fave.PreApproved: + // If approval is required and fave is + // preapproved, that means this is a fave + // of one of our statuses with permission + // that matched on a following/followers + // collection. Do the Accept immediately and + // then process everything else as normal, + // sending out the Like with the approval + // URI attached. + + // Put approval in the database and + // update the fave with approvedBy URI. + approval, err := p.utils.approveFave(ctx, fave) + if err != nil { + return gtserror.Newf("error pre-approving fave: %w", err) + } + + // Send out the approval as Accept. + if err := p.federate.AcceptInteraction(ctx, approval); err != nil { + return gtserror.Newf("error federating pre-approval of fave: %w", err) + } + + // Don't return, just continue as normal. + } + if err := p.surface.notifyFave(ctx, fave); err != nil { log.Errorf(ctx, "error notifying fave: %v", err) } @@ -383,6 +505,61 @@ func (p *clientAPI) CreateAnnounce(ctx context.Context, cMsg *messages.FromClien return gtserror.Newf("%T not parseable as *gtsmodel.Status", cMsg.GTSModel) } + // If pending approval is true then status must + // boost a status (either one of ours or a + // remote) that requires approval for the boost. + pendingApproval := util.PtrOrValue( + boost.PendingApproval, + false, + ) + + switch { + case pendingApproval && !boost.PreApproved: + // If approval is required and boost isn't + // preapproved, then send out the Announce to + // only the boosted account (if it's remote), + // and/or notify the account that's being + // interacted with (if it's local): they can + // approve or deny the interaction later. + + // Notify *local* account of pending announce. + if err := p.surface.notifyPendingAnnounce(ctx, boost); err != nil { + log.Errorf(ctx, "error notifying pending boost: %v", err) + } + + // Send Announce to *remote* account inbox ONLY. + if err := p.federate.Announce(ctx, boost); err != nil { + log.Errorf(ctx, "error federating pending Announce: %v", err) + } + + // Return early. + return nil + + case pendingApproval && boost.PreApproved: + // If approval is required and boost is + // preapproved, that means this is a boost + // of one of our statuses with permission + // that matched on a following/followers + // collection. Do the Accept immediately and + // then process everything else as normal, + // sending out the Create with the approval + // URI attached. + + // Put approval in the database and + // update the boost with approvedBy URI. + approval, err := p.utils.approveAnnounce(ctx, boost) + if err != nil { + return gtserror.Newf("error pre-approving boost: %w", err) + } + + // Send out the approval as Accept. + if err := p.federate.AcceptInteraction(ctx, approval); err != nil { + return gtserror.Newf("error federating pre-approval of boost: %w", err) + } + + // Don't return, just continue as normal. + } + // Update stats for the actor account. if err := p.utils.incrementStatusesCount(ctx, cMsg.Origin, boost); err != nil { log.Errorf(ctx, "error updating account stats: %v", err) @@ -874,3 +1051,18 @@ func (p *clientAPI) RejectUser(ctx context.Context, cMsg *messages.FromClientAPI return nil } + +func (p *clientAPI) AcceptLike(ctx context.Context, cMsg *messages.FromClientAPI) error { + // TODO + return nil +} + +func (p *clientAPI) AcceptReply(ctx context.Context, cMsg *messages.FromClientAPI) error { + // TODO + return nil +} + +func (p *clientAPI) AcceptAnnounce(ctx context.Context, cMsg *messages.FromClientAPI) error { + // TODO + return nil +} diff --git a/internal/processing/workers/fromfediapi.go b/internal/processing/workers/fromfediapi.go index ac4003f6a..63d1f0d16 100644 --- a/internal/processing/workers/fromfediapi.go +++ b/internal/processing/workers/fromfediapi.go @@ -122,11 +122,23 @@ func (p *Processor) ProcessFromFediAPI(ctx context.Context, fMsg *messages.FromF // ACCEPT SOMETHING case ap.ActivityAccept: - switch fMsg.APObjectType { //nolint:gocritic + switch fMsg.APObjectType { - // ACCEPT FOLLOW + // ACCEPT (pending) FOLLOW case ap.ActivityFollow: return p.fediAPI.AcceptFollow(ctx, fMsg) + + // ACCEPT (pending) LIKE + case ap.ActivityLike: + return p.fediAPI.AcceptLike(ctx, fMsg) + + // ACCEPT (pending) REPLY + case ap.ObjectNote: + return p.fediAPI.AcceptReply(ctx, fMsg) + + // ACCEPT (pending) ANNOUNCE + case ap.ActivityAnnounce: + return p.fediAPI.AcceptAnnounce(ctx, fMsg) } // DELETE SOMETHING @@ -216,6 +228,52 @@ func (p *fediAPI) CreateStatus(ctx context.Context, fMsg *messages.FromFediAPI) return nil } + // If pending approval is true then + // status must reply to a LOCAL status + // that requires approval for the reply. + pendingApproval := util.PtrOrValue( + status.PendingApproval, + false, + ) + + switch { + case pendingApproval && !status.PreApproved: + // If approval is required and status isn't + // preapproved, then just notify the account + // that's being interacted with: they can + // approve or deny the interaction later. + + // Notify *local* account of pending reply. + if err := p.surface.notifyPendingReply(ctx, status); err != nil { + log.Errorf(ctx, "error notifying pending reply: %v", err) + } + + // Return early. + return nil + + case pendingApproval && status.PreApproved: + // If approval is required and status is + // preapproved, that means this is a reply + // to one of our statuses with permission + // that matched on a following/followers + // collection. Do the Accept immediately and + // then process everything else as normal. + + // Put approval in the database and + // update the status with approvedBy URI. + approval, err := p.utils.approveReply(ctx, status) + if err != nil { + return gtserror.Newf("error pre-approving reply: %w", err) + } + + // Send out the approval as Accept. + if err := p.federate.AcceptInteraction(ctx, approval); err != nil { + return gtserror.Newf("error federating pre-approval of reply: %w", err) + } + + // Don't return, just continue as normal. + } + // Update stats for the remote account. if err := p.utils.incrementStatusesCount(ctx, fMsg.Requesting, status); err != nil { log.Errorf(ctx, "error updating account stats: %v", err) @@ -348,6 +406,52 @@ func (p *fediAPI) CreateLike(ctx context.Context, fMsg *messages.FromFediAPI) er return gtserror.Newf("error populating status fave: %w", err) } + // If pending approval is true then + // fave must target a LOCAL status + // that requires approval for the fave. + pendingApproval := util.PtrOrValue( + fave.PendingApproval, + false, + ) + + switch { + case pendingApproval && !fave.PreApproved: + // If approval is required and fave isn't + // preapproved, then just notify the account + // that's being interacted with: they can + // approve or deny the interaction later. + + // Notify *local* account of pending fave. + if err := p.surface.notifyPendingFave(ctx, fave); err != nil { + log.Errorf(ctx, "error notifying pending fave: %v", err) + } + + // Return early. + return nil + + case pendingApproval && fave.PreApproved: + // If approval is required and fave is + // preapproved, that means this is a fave + // of one of our statuses with permission + // that matched on a following/followers + // collection. Do the Accept immediately and + // then process everything else as normal. + + // Put approval in the database and + // update the fave with approvedBy URI. + approval, err := p.utils.approveFave(ctx, fave) + if err != nil { + return gtserror.Newf("error pre-approving fave: %w", err) + } + + // Send out the approval as Accept. + if err := p.federate.AcceptInteraction(ctx, approval); err != nil { + return gtserror.Newf("error federating pre-approval of fave: %w", err) + } + + // Don't return, just continue as normal. + } + if err := p.surface.notifyFave(ctx, fave); err != nil { log.Errorf(ctx, "error notifying fave: %v", err) } @@ -365,8 +469,9 @@ func (p *fediAPI) CreateAnnounce(ctx context.Context, fMsg *messages.FromFediAPI return gtserror.Newf("%T not parseable as *gtsmodel.Status", fMsg.GTSModel) } - // Dereference status that this boosts, note - // that this will handle storing the boost in + // Dereference into a boost wrapper status. + // + // Note: this will handle storing the boost in // the db, and dereferencing the target status // ancestors / descendants where appropriate. var err error @@ -376,8 +481,10 @@ func (p *fediAPI) CreateAnnounce(ctx context.Context, fMsg *messages.FromFediAPI fMsg.Receiving.Username, ) if err != nil { - if gtserror.IsUnretrievable(err) { - // Boosted status domain blocked, nothing to do. + if gtserror.IsUnretrievable(err) || + gtserror.NotPermitted(err) { + // Boosted status domain blocked, or + // otherwise not permitted, nothing to do. log.Debugf(ctx, "skipping announce: %v", err) return nil } @@ -386,6 +493,52 @@ func (p *fediAPI) CreateAnnounce(ctx context.Context, fMsg *messages.FromFediAPI return gtserror.Newf("error dereferencing announce: %w", err) } + // If pending approval is true then + // boost must target a LOCAL status + // that requires approval for the boost. + pendingApproval := util.PtrOrValue( + boost.PendingApproval, + false, + ) + + switch { + case pendingApproval && !boost.PreApproved: + // If approval is required and boost isn't + // preapproved, then just notify the account + // that's being interacted with: they can + // approve or deny the interaction later. + + // Notify *local* account of pending announce. + if err := p.surface.notifyPendingAnnounce(ctx, boost); err != nil { + log.Errorf(ctx, "error notifying pending boost: %v", err) + } + + // Return early. + return nil + + case pendingApproval && boost.PreApproved: + // If approval is required and status is + // preapproved, that means this is a boost + // of one of our statuses with permission + // that matched on a following/followers + // collection. Do the Accept immediately and + // then process everything else as normal. + + // Put approval in the database and + // update the boost with approvedBy URI. + approval, err := p.utils.approveAnnounce(ctx, boost) + if err != nil { + return gtserror.Newf("error pre-approving boost: %w", err) + } + + // Send out the approval as Accept. + if err := p.federate.AcceptInteraction(ctx, approval); err != nil { + return gtserror.Newf("error federating pre-approval of boost: %w", err) + } + + // Don't return, just continue as normal. + } + // Update stats for the remote account. if err := p.utils.incrementStatusesCount(ctx, fMsg.Requesting, boost); err != nil { log.Errorf(ctx, "error updating account stats: %v", err) @@ -549,6 +702,68 @@ func (p *fediAPI) AcceptFollow(ctx context.Context, fMsg *messages.FromFediAPI) return nil } +func (p *fediAPI) AcceptLike(ctx context.Context, fMsg *messages.FromFediAPI) error { + // TODO: Add something here if we ever implement sending out Likes to + // followers more broadly and not just the owner of the Liked status. + return nil +} + +func (p *fediAPI) AcceptReply(ctx context.Context, fMsg *messages.FromFediAPI) error { + status, ok := fMsg.GTSModel.(*gtsmodel.Status) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.Status", fMsg.GTSModel) + } + + // Update stats for the actor account. + if err := p.utils.incrementStatusesCount(ctx, status.Account, status); err != nil { + log.Errorf(ctx, "error updating account stats: %v", err) + } + + // Timeline and notify the status. + if err := p.surface.timelineAndNotifyStatus(ctx, status); err != nil { + log.Errorf(ctx, "error timelining and notifying status: %v", err) + } + + // Interaction counts changed on the replied-to status; + // uncache the prepared version from all timelines. + p.surface.invalidateStatusFromTimelines(ctx, status.InReplyToID) + + // Send out the reply again, fully this time. + if err := p.federate.CreateStatus(ctx, status); err != nil { + log.Errorf(ctx, "error federating announce: %v", err) + } + + return nil +} + +func (p *fediAPI) AcceptAnnounce(ctx context.Context, fMsg *messages.FromFediAPI) error { + boost, ok := fMsg.GTSModel.(*gtsmodel.Status) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.Status", fMsg.GTSModel) + } + + // Update stats for the actor account. + if err := p.utils.incrementStatusesCount(ctx, boost.Account, boost); err != nil { + log.Errorf(ctx, "error updating account stats: %v", err) + } + + // Timeline and notify the boost wrapper status. + if err := p.surface.timelineAndNotifyStatus(ctx, boost); err != nil { + log.Errorf(ctx, "error timelining and notifying status: %v", err) + } + + // Interaction counts changed on the boosted status; + // uncache the prepared version from all timelines. + p.surface.invalidateStatusFromTimelines(ctx, boost.BoostOfID) + + // Send out the boost again, fully this time. + if err := p.federate.Announce(ctx, boost); err != nil { + log.Errorf(ctx, "error federating announce: %v", err) + } + + return nil +} + func (p *fediAPI) UpdateStatus(ctx context.Context, fMsg *messages.FromFediAPI) error { // Cast the existing Status model attached to msg. existing, ok := fMsg.GTSModel.(*gtsmodel.Status) diff --git a/internal/processing/workers/surfacenotify.go b/internal/processing/workers/surfacenotify.go index edeb4b57e..872ccca65 100644 --- a/internal/processing/workers/surfacenotify.go +++ b/internal/processing/workers/surfacenotify.go @@ -32,6 +32,62 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/util" ) +// notifyPendingReply notifies the account replied-to +// by the given status that they have a new reply, +// and that approval is pending. +func (s *Surface) notifyPendingReply( + ctx context.Context, + status *gtsmodel.Status, +) error { + // Beforehand, ensure the passed status is fully populated. + if err := s.State.DB.PopulateStatus(ctx, status); err != nil { + return gtserror.Newf("error populating status %s: %w", status.ID, err) + } + + if status.InReplyToAccount.IsRemote() { + // Don't notify + // remote accounts. + return nil + } + + if status.AccountID == status.InReplyToAccountID { + // Don't notify + // self-replies. + return nil + } + + // Ensure thread not muted + // by replied-to account. + muted, err := s.State.DB.IsThreadMutedByAccount( + ctx, + status.ThreadID, + status.InReplyToAccountID, + ) + if err != nil { + return gtserror.Newf("error checking status thread mute %s: %w", status.ThreadID, err) + } + + if muted { + // The replied-to account + // has muted the thread. + // Don't pester them. + return nil + } + + // notify mentioned + // by status author. + if err := s.Notify(ctx, + gtsmodel.NotificationPendingReply, + status.InReplyToAccount, + status.Account, + status.ID, + ); err != nil { + return gtserror.Newf("error notifying replied-to account %s: %w", status.InReplyToAccountID, err) + } + + return nil +} + // notifyMentions iterates through mentions on the // given status, and notifies each mentioned account // that they have a new mention. @@ -181,20 +237,82 @@ func (s *Surface) notifyFave( ctx context.Context, fave *gtsmodel.StatusFave, ) error { + notifyable, err := s.notifyableFave(ctx, fave) + if err != nil { + return err + } + + if !notifyable { + // Nothing to do. + return nil + } + + // notify status author + // of fave by account. + if err := s.Notify(ctx, + gtsmodel.NotificationFave, + fave.TargetAccount, + fave.Account, + fave.StatusID, + ); err != nil { + return gtserror.Newf("error notifying status author %s: %w", fave.TargetAccountID, err) + } + + return nil +} + +// notifyPendingFave notifies the target of the +// given fave that their status has been faved +// and that approval is required. +func (s *Surface) notifyPendingFave( + ctx context.Context, + fave *gtsmodel.StatusFave, +) error { + notifyable, err := s.notifyableFave(ctx, fave) + if err != nil { + return err + } + + if !notifyable { + // Nothing to do. + return nil + } + + // notify status author + // of fave by account. + if err := s.Notify(ctx, + gtsmodel.NotificationPendingFave, + fave.TargetAccount, + fave.Account, + fave.StatusID, + ); err != nil { + return gtserror.Newf("error notifying status author %s: %w", fave.TargetAccountID, err) + } + + return nil +} + +// notifyableFave checks that the given +// fave should be notified, taking account +// of localness of receiving account, and mutes. +func (s *Surface) notifyableFave( + ctx context.Context, + fave *gtsmodel.StatusFave, +) (bool, error) { if fave.TargetAccountID == fave.AccountID { // Self-fave, nothing to do. - return nil + return false, nil } // Beforehand, ensure the passed status fave is fully populated. if err := s.State.DB.PopulateStatusFave(ctx, fave); err != nil { - return gtserror.Newf("error populating fave %s: %w", fave.ID, err) + return false, gtserror.Newf("error populating fave %s: %w", fave.ID, err) } if fave.TargetAccount.IsRemote() { // no need to notify // remote accounts. - return nil + return false, nil } // Ensure favee hasn't @@ -205,54 +323,105 @@ func (s *Surface) notifyFave( fave.TargetAccountID, ) if err != nil { - return gtserror.Newf("error checking status thread mute %s: %w", fave.StatusID, err) + return false, gtserror.Newf("error checking status thread mute %s: %w", fave.StatusID, err) } if muted { // Favee doesn't want // notifs for this thread. + return false, nil + } + + return true, nil +} + +// notifyAnnounce notifies the status boost target +// account that their status has been boosted. +func (s *Surface) notifyAnnounce( + ctx context.Context, + boost *gtsmodel.Status, +) error { + notifyable, err := s.notifyableAnnounce(ctx, boost) + if err != nil { + return err + } + + if !notifyable { + // Nothing to do. return nil } // notify status author - // of fave by account. + // of boost by account. if err := s.Notify(ctx, - gtsmodel.NotificationFave, - fave.TargetAccount, - fave.Account, - fave.StatusID, + gtsmodel.NotificationReblog, + boost.BoostOfAccount, + boost.Account, + boost.ID, ); err != nil { - return gtserror.Newf("error notifying status author %s: %w", fave.TargetAccountID, err) + return gtserror.Newf("error notifying boost target %s: %w", boost.BoostOfAccountID, err) } return nil } -// notifyAnnounce notifies the status boost target -// account that their status has been boosted. -func (s *Surface) notifyAnnounce( +// notifyPendingAnnounce notifies the status boost +// target account that their status has been boosted, +// and that the boost requires approval. +func (s *Surface) notifyPendingAnnounce( ctx context.Context, - status *gtsmodel.Status, + boost *gtsmodel.Status, ) error { + notifyable, err := s.notifyableAnnounce(ctx, boost) + if err != nil { + return err + } + + if !notifyable { + // Nothing to do. + return nil + } + + // notify status author + // of boost by account. + if err := s.Notify(ctx, + gtsmodel.NotificationPendingReblog, + boost.BoostOfAccount, + boost.Account, + boost.ID, + ); err != nil { + return gtserror.Newf("error notifying boost target %s: %w", boost.BoostOfAccountID, err) + } + + return nil +} + +// notifyableAnnounce checks that the given +// announce should be notified, taking account +// of localness of receiving account, and mutes. +func (s *Surface) notifyableAnnounce( + ctx context.Context, + status *gtsmodel.Status, +) (bool, error) { if status.BoostOfID == "" { // Not a boost, nothing to do. - return nil + return false, nil } if status.BoostOfAccountID == status.AccountID { // Self-boost, nothing to do. - return nil + return false, nil } // Beforehand, ensure the passed status is fully populated. if err := s.State.DB.PopulateStatus(ctx, status); err != nil { - return gtserror.Newf("error populating status %s: %w", status.ID, err) + return false, gtserror.Newf("error populating status %s: %w", status.ID, err) } if status.BoostOfAccount.IsRemote() { // no need to notify // remote accounts. - return nil + return false, nil } // Ensure boostee hasn't @@ -264,27 +433,16 @@ func (s *Surface) notifyAnnounce( ) if err != nil { - return gtserror.Newf("error checking status thread mute %s: %w", status.BoostOfID, err) + return false, gtserror.Newf("error checking status thread mute %s: %w", status.BoostOfID, err) } if muted { // Boostee doesn't want // notifs for this thread. - return nil + return false, nil } - // notify status author - // of boost by account. - if err := s.Notify(ctx, - gtsmodel.NotificationReblog, - status.BoostOfAccount, - status.Account, - status.ID, - ); err != nil { - return gtserror.Newf("error notifying status author %s: %w", status.BoostOfAccountID, err) - } - - return nil + return true, nil } func (s *Surface) notifyPollClose(ctx context.Context, status *gtsmodel.Status) error { diff --git a/internal/processing/workers/util.go b/internal/processing/workers/util.go index 915370976..994242d37 100644 --- a/internal/processing/workers/util.go +++ b/internal/processing/workers/util.go @@ -26,10 +26,13 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/processing/account" "github.com/superseriousbusiness/gotosocial/internal/processing/media" "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/superseriousbusiness/gotosocial/internal/uris" + "github.com/superseriousbusiness/gotosocial/internal/util" ) // util provides util functions used by both @@ -498,3 +501,129 @@ func (u *utils) decrementFollowRequestsCount( return nil } + +// approveFave stores + returns an +// interactionApproval for a fave. +func (u *utils) approveFave( + ctx context.Context, + fave *gtsmodel.StatusFave, +) (*gtsmodel.InteractionApproval, error) { + id := id.NewULID() + + approval := >smodel.InteractionApproval{ + ID: id, + AccountID: fave.TargetAccountID, + Account: fave.TargetAccount, + InteractingAccountID: fave.AccountID, + InteractingAccount: fave.Account, + InteractionURI: fave.URI, + InteractionType: gtsmodel.InteractionLike, + URI: uris.GenerateURIForAccept(fave.TargetAccount.Username, id), + } + + if err := u.state.DB.PutInteractionApproval(ctx, approval); err != nil { + err := gtserror.Newf("db error inserting interaction approval: %w", err) + return nil, err + } + + // Mark the fave itself as now approved. + fave.PendingApproval = util.Ptr(false) + fave.PreApproved = false + fave.ApprovedByURI = approval.URI + + if err := u.state.DB.UpdateStatusFave( + ctx, + fave, + "pending_approval", + "approved_by_uri", + ); err != nil { + err := gtserror.Newf("db error updating status fave: %w", err) + return nil, err + } + + return approval, nil +} + +// approveReply stores + returns an +// interactionApproval for a reply. +func (u *utils) approveReply( + ctx context.Context, + status *gtsmodel.Status, +) (*gtsmodel.InteractionApproval, error) { + id := id.NewULID() + + approval := >smodel.InteractionApproval{ + ID: id, + AccountID: status.InReplyToAccountID, + Account: status.InReplyToAccount, + InteractingAccountID: status.AccountID, + InteractingAccount: status.Account, + InteractionURI: status.URI, + InteractionType: gtsmodel.InteractionReply, + URI: uris.GenerateURIForAccept(status.InReplyToAccount.Username, id), + } + + if err := u.state.DB.PutInteractionApproval(ctx, approval); err != nil { + err := gtserror.Newf("db error inserting interaction approval: %w", err) + return nil, err + } + + // Mark the status itself as now approved. + status.PendingApproval = util.Ptr(false) + status.PreApproved = false + status.ApprovedByURI = approval.URI + + if err := u.state.DB.UpdateStatus( + ctx, + status, + "pending_approval", + "approved_by_uri", + ); err != nil { + err := gtserror.Newf("db error updating status: %w", err) + return nil, err + } + + return approval, nil +} + +// approveAnnounce stores + returns an +// interactionApproval for an announce. +func (u *utils) approveAnnounce( + ctx context.Context, + boost *gtsmodel.Status, +) (*gtsmodel.InteractionApproval, error) { + id := id.NewULID() + + approval := >smodel.InteractionApproval{ + ID: id, + AccountID: boost.BoostOfAccountID, + Account: boost.BoostOfAccount, + InteractingAccountID: boost.AccountID, + InteractingAccount: boost.Account, + InteractionURI: boost.URI, + InteractionType: gtsmodel.InteractionReply, + URI: uris.GenerateURIForAccept(boost.BoostOfAccount.Username, id), + } + + if err := u.state.DB.PutInteractionApproval(ctx, approval); err != nil { + err := gtserror.Newf("db error inserting interaction approval: %w", err) + return nil, err + } + + // Mark the status itself as now approved. + boost.PendingApproval = util.Ptr(false) + boost.PreApproved = false + boost.ApprovedByURI = approval.URI + + if err := u.state.DB.UpdateStatus( + ctx, + boost, + "pending_approval", + "approved_by_uri", + ); err != nil { + err := gtserror.Newf("db error updating boost wrapper status: %w", err) + return nil, err + } + + return approval, nil +} diff --git a/internal/regexes/regexes.go b/internal/regexes/regexes.go index aca502345..799557657 100644 --- a/internal/regexes/regexes.go +++ b/internal/regexes/regexes.go @@ -38,6 +38,7 @@ const ( follow = "follow" blocks = "blocks" reports = "reports" + accepts = "accepts" schemes = `(http|https)://` // Allowed URI protocols for parsing links in text. alphaNumeric = `\p{L}\p{M}*|\p{N}` // A single number or script character in any language, including chars with accents. @@ -71,6 +72,7 @@ const ( followPath = userPathPrefix + `/` + follow + `/(` + ulid + `)$` likePath = userPathPrefix + `/` + liked + `/(` + ulid + `)$` statusesPath = userPathPrefix + `/` + statuses + `/(` + ulid + `)$` + acceptsPath = userPathPrefix + `/` + accepts + `/(` + ulid + `)$` blockPath = userPathPrefix + `/` + blocks + `/(` + ulid + `)$` reportPath = `^/?` + reports + `/(` + ulid + `)$` filePath = `^/?(` + ulid + `)/([a-z]+)/([a-z]+)/(` + ulid + `)\.([a-z0-9]+)$` @@ -158,6 +160,10 @@ var ( // from eg /reports/01GP3AWY4CRDVRNZKW0TEAMB5R ReportPath = regexp.MustCompile(reportPath) + // ReportPath parses a path that validates and captures the username part and the ulid part + // from eg /users/example_username/accepts/01GP3AWY4CRDVRNZKW0TEAMB5R + AcceptsPath = regexp.MustCompile(acceptsPath) + // FilePath parses a file storage path of the form [ACCOUNT_ID]/[MEDIA_TYPE]/[MEDIA_SIZE]/[FILE_NAME] // eg 01F8MH1H7YV1Z7D2C8K2730QBF/attachment/small/01F8MH8RMYQ6MSNY3JM2XT1CQ5.jpeg // It captures the account id, media type, media size, file name, and file extension, eg diff --git a/internal/transport/controller.go b/internal/transport/controller.go index 519298d8e..3a529a8f3 100644 --- a/internal/transport/controller.go +++ b/internal/transport/controller.go @@ -204,6 +204,38 @@ func (c *controller) dereferenceLocalUser(ctx context.Context, iri *url.URL) (*h return rsp, nil } +// dereferenceLocalAccept is a shortcut to dereference an accept created +// by an account on this instance, without making any external api/http calls. +// +// It is passed to new transports, and should only be invoked when the iri.Host == this host. +func (c *controller) dereferenceLocalAccept(ctx context.Context, iri *url.URL) (*http.Response, error) { + accept, err := c.fedDB.GetAccept(ctx, iri) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return nil, err + } + + if accept == nil { + // Return a generic 404 not found response. + rsp := craftResponse(iri, http.StatusNotFound) + return rsp, nil + } + + i, err := ap.Serialize(accept) + if err != nil { + return nil, err + } + + b, err := json.Marshal(i) + if err != nil { + return nil, err + } + + // Return a response with AS data as body. + rsp := craftResponse(iri, http.StatusOK) + rsp.Body = io.NopCloser(bytes.NewReader(b)) + return rsp, nil +} + func craftResponse(url *url.URL, code int) *http.Response { rsp := new(http.Response) rsp.Request = new(http.Request) diff --git a/internal/transport/dereference.go b/internal/transport/dereference.go index 952791f70..8cc1f2103 100644 --- a/internal/transport/dereference.go +++ b/internal/transport/dereference.go @@ -29,17 +29,26 @@ import ( ) func (t *transport) Dereference(ctx context.Context, iri *url.URL) (*http.Response, error) { - // if the request is to us, we can shortcut for certain URIs rather than going through - // the normal request flow, thereby saving time and energy + // If the request is to us, we can shortcut for + // certain URIs rather than going through the normal + // request flow, thereby saving time and energy. if iri.Host == config.GetHost() { - if uris.IsFollowersPath(iri) { - // the request is for followers of one of our accounts, which we can shortcut + switch { + + case uris.IsFollowersPath(iri): + // The request is for followers of one of + // our accounts, which we can shortcut. return t.controller.dereferenceLocalFollowers(ctx, iri) - } - if uris.IsUserPath(iri) { - // the request is for one of our accounts, which we can shortcut + case uris.IsUserPath(iri): + // The request is for one of our + // accounts, which we can shortcut. return t.controller.dereferenceLocalUser(ctx, iri) + + case uris.IsAcceptsPath(iri): + // The request is for an Accept on + // our instance, which we can shortcut. + return t.controller.dereferenceLocalAccept(ctx, iri) } } diff --git a/internal/typeutils/astointernal.go b/internal/typeutils/astointernal.go index cb3e320d9..2946c8d09 100644 --- a/internal/typeutils/astointernal.go +++ b/internal/typeutils/astointernal.go @@ -393,13 +393,23 @@ func (c *Converter) ASStatusToStatus(ctx context.Context, statusable ap.Statusab return nil, gtserror.SetMalformed(err) } - // Advanced visibility toggles for this status. - // - // TODO: a lot of work to be done here -- a new type - // needs to be created for this in go-fed/activity. - // Until this is implemented, assume all true. + // Status was sent to us or dereffed + // by us so it must be federated. status.Federated = util.Ptr(true) + // Derive interaction policy for this status. + status.InteractionPolicy = ap.ExtractInteractionPolicy( + statusable, + status.Account, + ) + + // Set approvedByURI if present, + // for later dereferencing. + approvedByURI := ap.GetApprovedBy(statusable) + if approvedByURI != nil { + status.ApprovedByURI = approvedByURI.String() + } + // status.Sensitive sensitive := ap.ExtractSensitive(statusable) status.Sensitive = &sensitive diff --git a/internal/typeutils/internaltoas.go b/internal/typeutils/internaltoas.go index 567493673..31b256b6c 100644 --- a/internal/typeutils/internaltoas.go +++ b/internal/typeutils/internaltoas.go @@ -36,6 +36,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/uris" + "github.com/superseriousbusiness/gotosocial/internal/util" ) // AccountToAS converts a gts model account into an activity streams person, suitable for federation @@ -672,6 +673,38 @@ func (c *Converter) StatusToAS(ctx context.Context, s *gtsmodel.Status) (ap.Stat sensitiveProp.AppendXMLSchemaBoolean(*s.Sensitive) status.SetActivityStreamsSensitive(sensitiveProp) + // interactionPolicy + var p *gtsmodel.InteractionPolicy + if s.InteractionPolicy != nil { + // Use InteractionPolicy + // set on the status. + p = s.InteractionPolicy + } else { + // Fall back to default policy + // for the status's visibility. + p = gtsmodel.DefaultInteractionPolicyFor(s.Visibility) + } + policy, err := c.InteractionPolicyToASInteractionPolicy(ctx, p, s) + if err != nil { + return nil, fmt.Errorf("error creating interactionPolicy: %w", err) + } + + policyProp := streams.NewGoToSocialInteractionPolicyProperty() + policyProp.AppendGoToSocialInteractionPolicy(policy) + status.SetGoToSocialInteractionPolicy(policyProp) + + // Parse + set approvedBy. + if s.ApprovedByURI != "" { + approvedBy, err := url.Parse(s.ApprovedByURI) + if err != nil { + return nil, fmt.Errorf("error parsing approvedBy: %w", err) + } + + approvedByProp := streams.NewGoToSocialApprovedByProperty() + approvedByProp.Set(approvedBy) + status.SetGoToSocialApprovedBy(approvedByProp) + } + return status, nil } @@ -1169,6 +1202,18 @@ func (c *Converter) FaveToAS(ctx context.Context, f *gtsmodel.StatusFave) (vocab toProp.AppendIRI(toIRI) like.SetActivityStreamsTo(toProp) + // Parse + set approvedBy. + if f.ApprovedByURI != "" { + approvedBy, err := url.Parse(f.ApprovedByURI) + if err != nil { + return nil, fmt.Errorf("error parsing approvedBy: %w", err) + } + + approvedByProp := streams.NewGoToSocialApprovedByProperty() + approvedByProp.Set(approvedBy) + like.SetGoToSocialApprovedBy(approvedByProp) + } + return like, nil } @@ -1247,6 +1292,18 @@ func (c *Converter) BoostToAS(ctx context.Context, boostWrapperStatus *gtsmodel. announce.SetActivityStreamsCc(ccProp) + // Parse + set approvedBy. + if boostWrapperStatus.ApprovedByURI != "" { + approvedBy, err := url.Parse(boostWrapperStatus.ApprovedByURI) + if err != nil { + return nil, fmt.Errorf("error parsing approvedBy: %w", err) + } + + approvedByProp := streams.NewGoToSocialApprovedByProperty() + approvedByProp.Set(approvedBy) + announce.SetGoToSocialApprovedBy(approvedByProp) + } + return announce, nil } @@ -1724,3 +1781,227 @@ func (c *Converter) PollVoteToASCreate( return create, nil } + +// populateValuesForProp appends the given PolicyValues +// to the given property, for the given status. +func populateValuesForProp[T ap.WithIRI]( + prop ap.Property[T], + status *gtsmodel.Status, + urns gtsmodel.PolicyValues, +) error { + iriStrs := make([]string, 0) + + for _, urn := range urns { + switch urn { + + case gtsmodel.PolicyValueAuthor: + iriStrs = append(iriStrs, status.Account.URI) + + case gtsmodel.PolicyValueMentioned: + for _, m := range status.Mentions { + iriStrs = append(iriStrs, m.TargetAccount.URI) + } + + case gtsmodel.PolicyValueFollowing: + iriStrs = append(iriStrs, status.Account.FollowingURI) + + case gtsmodel.PolicyValueFollowers: + iriStrs = append(iriStrs, status.Account.FollowersURI) + + case gtsmodel.PolicyValuePublic: + iriStrs = append(iriStrs, pub.PublicActivityPubIRI) + + default: + iriStrs = append(iriStrs, string(urn)) + } + } + + // Deduplicate the iri strings to + // make sure we're not parsing + adding + // the same string multiple times. + iriStrs = util.Deduplicate(iriStrs) + + // Append them to the property. + for _, iriStr := range iriStrs { + iri, err := url.Parse(iriStr) + if err != nil { + return err + } + + prop.AppendIRI(iri) + } + + return nil +} + +// InteractionPolicyToASInteractionPolicy returns a +// GoToSocial interaction policy suitable for federation. +func (c *Converter) InteractionPolicyToASInteractionPolicy( + ctx context.Context, + interactionPolicy *gtsmodel.InteractionPolicy, + status *gtsmodel.Status, +) (vocab.GoToSocialInteractionPolicy, error) { + policy := streams.NewGoToSocialInteractionPolicy() + + /* + CAN LIKE + */ + + // Build canLike + canLike := streams.NewGoToSocialCanLike() + + // Build canLike.always + canLikeAlwaysProp := streams.NewGoToSocialAlwaysProperty() + if err := populateValuesForProp( + canLikeAlwaysProp, + status, + interactionPolicy.CanLike.Always, + ); err != nil { + return nil, gtserror.Newf("error setting canLike.always: %w", err) + } + + // Set canLike.always + canLike.SetGoToSocialAlways(canLikeAlwaysProp) + + // Build canLike.approvalRequired + canLikeApprovalRequiredProp := streams.NewGoToSocialApprovalRequiredProperty() + if err := populateValuesForProp( + canLikeApprovalRequiredProp, + status, + interactionPolicy.CanLike.WithApproval, + ); err != nil { + return nil, gtserror.Newf("error setting canLike.approvalRequired: %w", err) + } + + // Set canLike.approvalRequired. + canLike.SetGoToSocialApprovalRequired(canLikeApprovalRequiredProp) + + // Set canLike on the policy. + canLikeProp := streams.NewGoToSocialCanLikeProperty() + canLikeProp.AppendGoToSocialCanLike(canLike) + policy.SetGoToSocialCanLike(canLikeProp) + + /* + CAN REPLY + */ + + // Build canReply + canReply := streams.NewGoToSocialCanReply() + + // Build canReply.always + canReplyAlwaysProp := streams.NewGoToSocialAlwaysProperty() + if err := populateValuesForProp( + canReplyAlwaysProp, + status, + interactionPolicy.CanReply.Always, + ); err != nil { + return nil, gtserror.Newf("error setting canReply.always: %w", err) + } + + // Set canReply.always + canReply.SetGoToSocialAlways(canReplyAlwaysProp) + + // Build canReply.approvalRequired + canReplyApprovalRequiredProp := streams.NewGoToSocialApprovalRequiredProperty() + if err := populateValuesForProp( + canReplyApprovalRequiredProp, + status, + interactionPolicy.CanReply.WithApproval, + ); err != nil { + return nil, gtserror.Newf("error setting canReply.approvalRequired: %w", err) + } + + // Set canReply.approvalRequired. + canReply.SetGoToSocialApprovalRequired(canReplyApprovalRequiredProp) + + // Set canReply on the policy. + canReplyProp := streams.NewGoToSocialCanReplyProperty() + canReplyProp.AppendGoToSocialCanReply(canReply) + policy.SetGoToSocialCanReply(canReplyProp) + + /* + CAN ANNOUNCE + */ + + // Build canAnnounce + canAnnounce := streams.NewGoToSocialCanAnnounce() + + // Build canAnnounce.always + canAnnounceAlwaysProp := streams.NewGoToSocialAlwaysProperty() + if err := populateValuesForProp( + canAnnounceAlwaysProp, + status, + interactionPolicy.CanAnnounce.Always, + ); err != nil { + return nil, gtserror.Newf("error setting canAnnounce.always: %w", err) + } + + // Set canAnnounce.always + canAnnounce.SetGoToSocialAlways(canAnnounceAlwaysProp) + + // Build canAnnounce.approvalRequired + canAnnounceApprovalRequiredProp := streams.NewGoToSocialApprovalRequiredProperty() + if err := populateValuesForProp( + canAnnounceApprovalRequiredProp, + status, + interactionPolicy.CanAnnounce.WithApproval, + ); err != nil { + return nil, gtserror.Newf("error setting canAnnounce.approvalRequired: %w", err) + } + + // Set canAnnounce.approvalRequired. + canAnnounce.SetGoToSocialApprovalRequired(canAnnounceApprovalRequiredProp) + + // Set canAnnounce on the policy. + canAnnounceProp := streams.NewGoToSocialCanAnnounceProperty() + canAnnounceProp.AppendGoToSocialCanAnnounce(canAnnounce) + policy.SetGoToSocialCanAnnounce(canAnnounceProp) + + return policy, nil +} + +// InteractionApprovalToASAccept converts a *gtsmodel.InteractionApproval +// to an ActivityStreams Accept, addressed to the interacting account. +func (c *Converter) InteractionApprovalToASAccept( + ctx context.Context, + approval *gtsmodel.InteractionApproval, +) (vocab.ActivityStreamsAccept, error) { + accept := streams.NewActivityStreamsAccept() + + acceptID, err := url.Parse(approval.URI) + if err != nil { + return nil, gtserror.Newf("invalid accept uri: %w", err) + } + + actorIRI, err := url.Parse(approval.Account.URI) + if err != nil { + return nil, gtserror.Newf("invalid account uri: %w", err) + } + + objectIRI, err := url.Parse(approval.InteractionURI) + if err != nil { + return nil, gtserror.Newf("invalid target uri: %w", err) + } + + toIRI, err := url.Parse(approval.InteractingAccount.URI) + if err != nil { + return nil, gtserror.Newf("invalid interacting account uri: %w", err) + } + + // Set id to the URI of + // interactionApproval. + ap.SetJSONLDId(accept, acceptID) + + // Actor is the account that + // owns the approval / accept. + ap.AppendActorIRIs(accept, actorIRI) + + // Object is the interaction URI. + ap.AppendObjectIRIs(accept, objectIRI) + + // Address to the owner + // of interaction URI. + ap.AppendTo(accept, toIRI) + + return accept, nil +} diff --git a/internal/typeutils/internaltoas_test.go b/internal/typeutils/internaltoas_test.go index ca8143436..905dccfad 100644 --- a/internal/typeutils/internaltoas_test.go +++ b/internal/typeutils/internaltoas_test.go @@ -21,8 +21,6 @@ import ( "context" "encoding/json" "errors" - "fmt" - "strings" "testing" "github.com/stretchr/testify/suite" @@ -46,14 +44,15 @@ func (suite *InternalToASTestSuite) TestAccountToAS() { ser, err := ap.Serialize(asPerson) suite.NoError(err) + // Drop "@context" property as + // the ordering is non-determinate. + delete(ser, "@context") + bytes, err := json.MarshalIndent(ser, "", " ") suite.NoError(err) - // trim off everything up to 'discoverable'; - // this is necessary because the order of multiple 'context' entries is not determinate - trimmed := strings.Split(string(bytes), "\"discoverable\"")[1] - - suite.Equal(`: true, + suite.Equal(`{ + "discoverable": true, "featured": "http://localhost:8080/users/the_mighty_zork/collections/featured", "followers": "http://localhost:8080/users/the_mighty_zork/followers", "following": "http://localhost:8080/users/the_mighty_zork/following", @@ -82,7 +81,7 @@ func (suite *InternalToASTestSuite) TestAccountToAS() { "tag": [], "type": "Person", "url": "http://localhost:8080/@the_mighty_zork" -}`, trimmed) +}`, string(bytes)) } func (suite *InternalToASTestSuite) TestAccountToASWithFields() { @@ -95,16 +94,15 @@ func (suite *InternalToASTestSuite) TestAccountToASWithFields() { ser, err := ap.Serialize(asPerson) suite.NoError(err) + // Drop "@context" property as + // the ordering is non-determinate. + delete(ser, "@context") + bytes, err := json.MarshalIndent(ser, "", " ") suite.NoError(err) - // trim off everything up to 'attachment'; - // this is necessary because the order of multiple 'context' entries is not determinate - trimmed := strings.Split(string(bytes), "\"attachment\"")[1] - - fmt.Printf("\n\n\n%s\n\n\n", string(bytes)) - - suite.Equal(`: [ + suite.Equal(`{ + "attachment": [ { "name": "should you follow me?", "type": "PropertyValue", @@ -135,7 +133,7 @@ func (suite *InternalToASTestSuite) TestAccountToASWithFields() { "tag": [], "type": "Person", "url": "http://localhost:8080/@1happyturtle" -}`, trimmed) +}`, string(bytes)) } func (suite *InternalToASTestSuite) TestAccountToASAliasedAndMoved() { @@ -161,14 +159,15 @@ func (suite *InternalToASTestSuite) TestAccountToASAliasedAndMoved() { ser, err := ap.Serialize(asPerson) suite.NoError(err) + // Drop "@context" property as + // the ordering is non-determinate. + delete(ser, "@context") + bytes, err := json.MarshalIndent(ser, "", " ") suite.NoError(err) - // trim off everything up to 'alsoKnownAs'; - // this is necessary because the order of multiple 'context' entries is not determinate - trimmed := strings.Split(string(bytes), "\"alsoKnownAs\"")[1] - - suite.Equal(`: [ + suite.Equal(`{ + "alsoKnownAs": [ "http://localhost:8080/users/1happyturtle" ], "discoverable": true, @@ -201,7 +200,7 @@ func (suite *InternalToASTestSuite) TestAccountToASAliasedAndMoved() { "tag": [], "type": "Person", "url": "http://localhost:8080/@the_mighty_zork" -}`, trimmed) +}`, string(bytes)) } func (suite *InternalToASTestSuite) TestAccountToASWithOneField() { @@ -215,15 +214,16 @@ func (suite *InternalToASTestSuite) TestAccountToASWithOneField() { ser, err := ap.Serialize(asPerson) suite.NoError(err) + // Drop "@context" property as + // the ordering is non-determinate. + delete(ser, "@context") + bytes, err := json.MarshalIndent(ser, "", " ") suite.NoError(err) - // trim off everything up to 'attachment'; - // this is necessary because the order of multiple 'context' entries is not determinate - trimmed := strings.Split(string(bytes), "\"attachment\"")[1] - // Despite only one field being set, attachments should still be a slice/array. - suite.Equal(`: [ + suite.Equal(`{ + "attachment": [ { "name": "should you follow me?", "type": "PropertyValue", @@ -249,7 +249,7 @@ func (suite *InternalToASTestSuite) TestAccountToASWithOneField() { "tag": [], "type": "Person", "url": "http://localhost:8080/@1happyturtle" -}`, trimmed) +}`, string(bytes)) } func (suite *InternalToASTestSuite) TestAccountToASWithEmoji() { @@ -263,14 +263,15 @@ func (suite *InternalToASTestSuite) TestAccountToASWithEmoji() { ser, err := ap.Serialize(asPerson) suite.NoError(err) + // Drop "@context" property as + // the ordering is non-determinate. + delete(ser, "@context") + bytes, err := json.MarshalIndent(ser, "", " ") suite.NoError(err) - // trim off everything up to 'discoverable'; - // this is necessary because the order of multiple 'context' entries is not determinate - trimmed := strings.Split(string(bytes), "\"discoverable\"")[1] - - suite.Equal(`: true, + suite.Equal(`{ + "discoverable": true, "featured": "http://localhost:8080/users/the_mighty_zork/collections/featured", "followers": "http://localhost:8080/users/the_mighty_zork/followers", "following": "http://localhost:8080/users/the_mighty_zork/following", @@ -309,7 +310,7 @@ func (suite *InternalToASTestSuite) TestAccountToASWithEmoji() { }, "type": "Person", "url": "http://localhost:8080/@the_mighty_zork" -}`, trimmed) +}`, string(bytes)) } func (suite *InternalToASTestSuite) TestAccountToASWithSharedInbox() { @@ -324,14 +325,15 @@ func (suite *InternalToASTestSuite) TestAccountToASWithSharedInbox() { ser, err := ap.Serialize(asPerson) suite.NoError(err) + // Drop "@context" property as + // the ordering is non-determinate. + delete(ser, "@context") + bytes, err := json.MarshalIndent(ser, "", " ") suite.NoError(err) - // trim off everything up to 'discoverable'; - // this is necessary because the order of multiple 'context' entries is not determinate - trimmed := strings.Split(string(bytes), "\"discoverable\"")[1] - - suite.Equal(`: true, + suite.Equal(`{ + "discoverable": true, "endpoints": { "sharedInbox": "http://localhost:8080/sharedInbox" }, @@ -363,7 +365,7 @@ func (suite *InternalToASTestSuite) TestAccountToASWithSharedInbox() { "tag": [], "type": "Person", "url": "http://localhost:8080/@the_mighty_zork" -}`, trimmed) +}`, string(bytes)) } func (suite *InternalToASTestSuite) TestStatusToAS() { @@ -376,11 +378,14 @@ func (suite *InternalToASTestSuite) TestStatusToAS() { ser, err := ap.Serialize(asStatus) suite.NoError(err) + // Drop "@context" property as + // the ordering is non-determinate. + delete(ser, "@context") + bytes, err := json.MarshalIndent(ser, "", " ") suite.NoError(err) suite.Equal(`{ - "@context": "https://www.w3.org/ns/activitystreams", "attachment": [], "attributedTo": "http://localhost:8080/users/the_mighty_zork", "cc": "http://localhost:8080/users/the_mighty_zork/followers", @@ -389,6 +394,26 @@ func (suite *InternalToASTestSuite) TestStatusToAS() { "en": "hello everyone!" }, "id": "http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY", + "interactionPolicy": { + "canAnnounce": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + }, + "canLike": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + }, + "canReply": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + } + }, "published": "2021-10-20T12:40:37+02:00", "replies": { "first": { @@ -420,14 +445,15 @@ func (suite *InternalToASTestSuite) TestStatusWithTagsToASWithIDs() { ser, err := ap.Serialize(asStatus) suite.NoError(err) + // Drop "@context" property as + // the ordering is non-determinate. + delete(ser, "@context") + bytes, err := json.MarshalIndent(ser, "", " ") suite.NoError(err) - // we can't be sure in what order the two context entries -- - // http://joinmastodon.org/ns, https://www.w3.org/ns/activitystreams -- - // will appear, so trim them out of the string for consistency - trimmed := strings.SplitAfter(string(bytes), `"attachment":`)[1] - suite.Equal(` [ + suite.Equal(`{ + "attachment": [ { "blurhash": "LIIE|gRj00WB-;j[t7j[4nWBj[Rj", "mediaType": "image/jpeg", @@ -443,6 +469,26 @@ func (suite *InternalToASTestSuite) TestStatusWithTagsToASWithIDs() { "en": "hello world! #welcome ! first post on the instance :rainbow: !" }, "id": "http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R", + "interactionPolicy": { + "canAnnounce": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + }, + "canLike": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + }, + "canReply": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + } + }, "published": "2021-10-20T11:36:45Z", "replies": { "first": { @@ -477,7 +523,7 @@ func (suite *InternalToASTestSuite) TestStatusWithTagsToASWithIDs() { "to": "https://www.w3.org/ns/activitystreams#Public", "type": "Note", "url": "http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R" -}`, trimmed) +}`, string(bytes)) } func (suite *InternalToASTestSuite) TestStatusWithTagsToASFromDB() { @@ -492,14 +538,15 @@ func (suite *InternalToASTestSuite) TestStatusWithTagsToASFromDB() { ser, err := ap.Serialize(asStatus) suite.NoError(err) + // Drop "@context" property as + // the ordering is non-determinate. + delete(ser, "@context") + bytes, err := json.MarshalIndent(ser, "", " ") suite.NoError(err) - // we can't be sure in what order the two context entries -- - // http://joinmastodon.org/ns, https://www.w3.org/ns/activitystreams -- - // will appear, so trim them out of the string for consistency - trimmed := strings.SplitAfter(string(bytes), `"attachment":`)[1] - suite.Equal(` [ + suite.Equal(`{ + "attachment": [ { "blurhash": "LIIE|gRj00WB-;j[t7j[4nWBj[Rj", "mediaType": "image/jpeg", @@ -515,6 +562,26 @@ func (suite *InternalToASTestSuite) TestStatusWithTagsToASFromDB() { "en": "hello world! #welcome ! first post on the instance :rainbow: !" }, "id": "http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R", + "interactionPolicy": { + "canAnnounce": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + }, + "canLike": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + }, + "canReply": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + } + }, "published": "2021-10-20T11:36:45Z", "replies": { "first": { @@ -549,7 +616,7 @@ func (suite *InternalToASTestSuite) TestStatusWithTagsToASFromDB() { "to": "https://www.w3.org/ns/activitystreams#Public", "type": "Note", "url": "http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R" -}`, trimmed) +}`, string(bytes)) } func (suite *InternalToASTestSuite) TestStatusToASWithMentions() { @@ -565,11 +632,14 @@ func (suite *InternalToASTestSuite) TestStatusToASWithMentions() { ser, err := ap.Serialize(asStatus) suite.NoError(err) + // Drop "@context" property as + // the ordering is non-determinate. + delete(ser, "@context") + bytes, err := json.MarshalIndent(ser, "", " ") suite.NoError(err) suite.Equal(`{ - "@context": "https://www.w3.org/ns/activitystreams", "attachment": [], "attributedTo": "http://localhost:8080/users/admin", "cc": [ @@ -582,6 +652,26 @@ func (suite *InternalToASTestSuite) TestStatusToASWithMentions() { }, "id": "http://localhost:8080/users/admin/statuses/01FF25D5Q0DH7CHD57CTRS6WK0", "inReplyTo": "http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY", + "interactionPolicy": { + "canAnnounce": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + }, + "canLike": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + }, + "canReply": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + } + }, "published": "2021-11-20T13:32:16Z", "replies": { "first": { @@ -967,6 +1057,51 @@ func (suite *InternalToASTestSuite) TestPollVoteToASCreate() { }`, string(bytes)) } +func (suite *InternalToASTestSuite) TestInteractionApprovalToASAccept() { + acceptingAccount := suite.testAccounts["local_account_1"] + interactingAccount := suite.testAccounts["remote_account_1"] + + interactionApproval := >smodel.InteractionApproval{ + ID: "01J1AKMZ8JE5NW0ZSFTRC1JJNE", + CreatedAt: testrig.TimeMustParse("2022-06-09T13:12:00Z"), + UpdatedAt: testrig.TimeMustParse("2022-06-09T13:12:00Z"), + AccountID: acceptingAccount.ID, + Account: acceptingAccount, + InteractingAccountID: interactingAccount.ID, + InteractingAccount: interactingAccount, + InteractionURI: "https://fossbros-anonymous.io/users/foss_satan/statuses/01J1AKRRHQ6MDDQHV0TP716T2K", + InteractionType: gtsmodel.InteractionAnnounce, + URI: "http://localhost:8080/users/the_mighty_zork/accepts/01J1AKMZ8JE5NW0ZSFTRC1JJNE", + } + + accept, err := suite.typeconverter.InteractionApprovalToASAccept( + context.Background(), + interactionApproval, + ) + if err != nil { + suite.FailNow(err.Error()) + } + + i, err := ap.Serialize(accept) + if err != nil { + suite.FailNow(err.Error()) + } + + b, err := json.MarshalIndent(i, "", " ") + if err != nil { + suite.FailNow(err.Error()) + } + + suite.Equal(`{ + "@context": "https://www.w3.org/ns/activitystreams", + "actor": "http://localhost:8080/users/the_mighty_zork", + "id": "http://localhost:8080/users/the_mighty_zork/accepts/01J1AKMZ8JE5NW0ZSFTRC1JJNE", + "object": "https://fossbros-anonymous.io/users/foss_satan/statuses/01J1AKRRHQ6MDDQHV0TP716T2K", + "to": "http://fossbros-anonymous.io/users/foss_satan", + "type": "Accept" +}`, string(b)) +} + func TestInternalToASTestSuite(t *testing.T) { suite.Run(t, new(InternalToASTestSuite)) } diff --git a/internal/typeutils/wrap_test.go b/internal/typeutils/wrap_test.go index 453073ed6..833b18bac 100644 --- a/internal/typeutils/wrap_test.go +++ b/internal/typeutils/wrap_test.go @@ -72,11 +72,14 @@ func (suite *WrapTestSuite) TestWrapNoteInCreate() { createI, err := ap.Serialize(create) suite.NoError(err) + // Chop off @context since + // ordering is non-determinate. + delete(createI, "@context") + bytes, err := json.MarshalIndent(createI, "", " ") suite.NoError(err) suite.Equal(`{ - "@context": "https://www.w3.org/ns/activitystreams", "actor": "http://localhost:8080/users/the_mighty_zork", "cc": "http://localhost:8080/users/the_mighty_zork/followers", "id": "http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/activity#Create", @@ -89,6 +92,26 @@ func (suite *WrapTestSuite) TestWrapNoteInCreate() { "en": "hello everyone!" }, "id": "http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY", + "interactionPolicy": { + "canAnnounce": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + }, + "canLike": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + }, + "canReply": { + "always": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "approvalRequired": [] + } + }, "published": "2021-10-20T12:40:37+02:00", "replies": { "first": { diff --git a/internal/uris/uri.go b/internal/uris/uri.go index 335461d84..159508176 100644 --- a/internal/uris/uri.go +++ b/internal/uris/uri.go @@ -46,6 +46,7 @@ const ( FileserverPath = "fileserver" // FileserverPath is a path component for serving attachments + media EmojiPath = "emoji" // EmojiPath represents the activitypub emoji location TagsPath = "tags" // TagsPath represents the activitypub tags location + AcceptsPath = "accepts" // AcceptsPath represents the activitypub accepts location ) // UserURIs contains a bunch of UserURIs and URLs for a user, host, account, etc. @@ -136,6 +137,14 @@ func GenerateURIForEmailConfirm(token string) string { return fmt.Sprintf("%s://%s/%s?token=%s", protocol, host, ConfirmEmailPath, token) } +// GenerateURIForAccept returns the AP URI for a new accept activity -- something like: +// https://example.org/users/whatever_user/accepts/01F7XTH1QGBAPMGF49WJZ91XGC +func GenerateURIForAccept(username string, thisAcceptID string) string { + protocol := config.GetProtocol() + host := config.GetHost() + return fmt.Sprintf("%s://%s/%s/%s/%s/%s", protocol, host, UsersPath, username, AcceptsPath, thisAcceptID) +} + // GenerateURIsForAccount throws together a bunch of URIs for the given username, with the given protocol and host. func GenerateURIsForAccount(username string) *UserURIs { protocol := config.GetProtocol() @@ -317,6 +326,11 @@ func IsReportPath(id *url.URL) bool { return regexes.ReportPath.MatchString(id.Path) } +// IsAcceptsPath returns true if the given URL path corresponds to eg /users/example_username/accepts/SOME_ULID_OF_AN_ACCEPT +func IsAcceptsPath(id *url.URL) bool { + return regexes.AcceptsPath.MatchString(id.Path) +} + // ParseStatusesPath returns the username and ulid from a path such as /users/example_username/statuses/SOME_ULID_OF_A_STATUS func ParseStatusesPath(id *url.URL) (username string, ulid string, err error) { matches := regexes.StatusesPath.FindStringSubmatch(id.Path) |