diff --git a/internal/ap/activitystreams.go b/internal/ap/activitystreams.go index f6c412e51..a78b0b61d 100644 --- a/internal/ap/activitystreams.go +++ b/internal/ap/activitystreams.go @@ -78,3 +78,49 @@ const ( // and https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tag TagHashtag = "Hashtag" ) + +// isActivity returns whether AS type name is of an Activity (NOT IntransitiveActivity). +func isActivity(typeName string) bool { + switch typeName { + case ActivityAccept, + ActivityTentativeAccept, + ActivityAdd, + ActivityCreate, + ActivityDelete, + ActivityFollow, + ActivityIgnore, + ActivityJoin, + ActivityLeave, + ActivityLike, + ActivityOffer, + ActivityInvite, + ActivityReject, + ActivityTentativeReject, + ActivityRemove, + ActivityUndo, + ActivityUpdate, + ActivityView, + ActivityListen, + ActivityRead, + ActivityMove, + ActivityAnnounce, + ActivityBlock, + ActivityFlag, + ActivityDislike: + return true + default: + return false + } +} + +// isIntransitiveActivity returns whether AS type name is of an IntransitiveActivity. +func isIntransitiveActivity(typeName string) bool { + switch typeName { + case ActivityArrive, + ActivityTravel, + ActivityQuestion: + return true + default: + return false + } +} diff --git a/internal/ap/extract.go b/internal/ap/extract.go index 21ff20235..4cefd22dc 100644 --- a/internal/ap/extract.go +++ b/internal/ap/extract.go @@ -28,12 +28,53 @@ import ( "time" "github.com/superseriousbusiness/activity/pub" + "github.com/superseriousbusiness/activity/streams/vocab" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/text" "github.com/superseriousbusiness/gotosocial/internal/util" ) +// ExtractObject will extract an object vocab.Type from given implementing interface. +func ExtractObject(with WithObject) vocab.Type { + // Extract the attached object (if any). + obj := with.GetActivityStreamsObject() + if obj == nil { + return nil + } + + // Only support single + // objects (for now...) + if obj.Len() != 1 { + return nil + } + + // Extract object vocab.Type. + return obj.At(0).GetType() +} + +// ExtractActivityData will extract the usable data type (e.g. Note, Question, etc) and corresponding JSON, from activity. +func ExtractActivityData(activity pub.Activity, rawJSON map[string]any) (vocab.Type, map[string]any, bool) { + switch typeName := activity.GetTypeName(); { + // Activity (has "object"). + case isActivity(typeName): + objType := ExtractObject(activity) + if objType == nil { + return nil, nil, false + } + objJSON, _ := rawJSON["object"].(map[string]any) + return objType, objJSON, true + + // IntransitiveAcitivity (no "object"). + case isIntransitiveActivity(typeName): + return activity, rawJSON, false + + // Unknown. + default: + return nil, nil, false + } +} + // ExtractPreferredUsername returns a string representation of // an interface's preferredUsername property. Will return an // error if preferredUsername is nil, not a string, or empty. @@ -497,6 +538,38 @@ func ExtractContent(i WithContent) string { return "" } +// ExtractAttachments attempts to extract barebones MediaAttachment objects from given AS interface type. +func ExtractAttachments(i WithAttachment) ([]*gtsmodel.MediaAttachment, error) { + attachmentProp := i.GetActivityStreamsAttachment() + if attachmentProp == nil { + return nil, nil + } + + var errs gtserror.MultiError + + attachments := make([]*gtsmodel.MediaAttachment, 0, attachmentProp.Len()) + for iter := attachmentProp.Begin(); iter != attachmentProp.End(); iter = iter.Next() { + t := iter.GetType() + if t == nil { + errs.Appendf("nil attachment type") + continue + } + attachmentable, ok := t.(Attachmentable) + if !ok { + errs.Appendf("incorrect attachment type: %T", t) + continue + } + attachment, err := ExtractAttachment(attachmentable) + if err != nil { + errs.Appendf("error extracting attachment: %w", err) + continue + } + attachments = append(attachments, attachment) + } + + return attachments, errs.Combine() +} + // ExtractAttachment extracts a minimal gtsmodel.Attachment // (just remote URL, description, and blurhash) from the given // Attachmentable interface, or an error if no remote URL is set. @@ -913,6 +986,52 @@ func ExtractSharedInbox(withEndpoints WithEndpoints) *url.URL { return nil } +// IterateOneOf will attempt to extract oneOf property from given interface, and passes each iterated item to function. +func IterateOneOf(withOneOf WithOneOf, foreach func(vocab.ActivityStreamsOneOfPropertyIterator)) { + if foreach == nil { + // nil check outside loop. + panic("nil function") + } + + // Extract the one-of property from interface. + oneOfProp := withOneOf.GetActivityStreamsOneOf() + if oneOfProp == nil { + return + } + + // Get start and end of iter. + start := oneOfProp.Begin() + end := oneOfProp.End() + + // Pass iterated oneOf entries to given function. + for iter := start; iter != end; iter = iter.Next() { + foreach(iter) + } +} + +// IterateAnyOf will attempt to extract anyOf property from given interface, and passes each iterated item to function. +func IterateAnyOf(withAnyOf WithAnyOf, foreach func(vocab.ActivityStreamsAnyOfPropertyIterator)) { + if foreach == nil { + // nil check outside loop. + panic("nil function") + } + + // Extract the any-of property from interface. + anyOfProp := withAnyOf.GetActivityStreamsAnyOf() + if anyOfProp == nil { + return + } + + // Get start and end of iter. + start := anyOfProp.Begin() + end := anyOfProp.End() + + // Pass iterated anyOf entries to given function. + for iter := start; iter != end; iter = iter.Next() { + foreach(iter) + } +} + // isPublic checks if at least one entry in the given // uris slice equals the activitystreams public uri. func isPublic(uris []*url.URL) bool { diff --git a/internal/ap/interfaces.go b/internal/ap/interfaces.go index 5372eb01e..4538c476f 100644 --- a/internal/ap/interfaces.go +++ b/internal/ap/interfaces.go @@ -23,11 +23,76 @@ import ( "github.com/superseriousbusiness/activity/streams/vocab" ) +// IsAccountable returns whether AS vocab type name is acceptable as Accountable. +func IsAccountable(typeName string) bool { + switch typeName { + case ActorPerson, + ActorApplication, + ActorOrganization, + ActorService, + ActorGroup: + return true + default: + return false + } +} + +// ToAccountable safely tries to cast vocab.Type as Accountable, also checking for expected AS type names. +func ToAccountable(t vocab.Type) (Accountable, bool) { + accountable, ok := t.(Accountable) + if !ok || !IsAccountable(t.GetTypeName()) { + return nil, false + } + return accountable, true +} + +// IsStatusable returns whether AS vocab type name is acceptable as Statusable. +func IsStatusable(typeName string) bool { + switch typeName { + case ObjectArticle, + ObjectDocument, + ObjectImage, + ObjectVideo, + ObjectNote, + ObjectPage, + ObjectEvent, + ObjectPlace, + ObjectProfile, + ActivityQuestion: + return true + default: + return false + } +} + +// ToStatusable safely tries to cast vocab.Type as Statusable, also checking for expected AS type names. +func ToStatusable(t vocab.Type) (Statusable, bool) { + statusable, ok := t.(Statusable) + if !ok || !IsStatusable(t.GetTypeName()) { + return nil, false + } + return statusable, true +} + +// IsPollable returns whether AS vocab type name is acceptable as Pollable. +func IsPollable(typeName string) bool { + return typeName == ActivityQuestion +} + +// ToPollable safely tries to cast vocab.Type as Pollable, also checking for expected AS type names. +func ToPollable(t vocab.Type) (Pollable, bool) { + pollable, ok := t.(Pollable) + if !ok || !IsPollable(t.GetTypeName()) { + return nil, false + } + return pollable, true +} + // Accountable represents the minimum activitypub interface for representing an 'account'. -// This interface is fulfilled by: Person, Application, Organization, Service, and Group +// (see: IsAccountable() for types implementing this, though you MUST make sure to check +// the typeName as this bare interface may be implementable by non-Accountable types). type Accountable interface { - WithJSONLDId - WithTypeName + vocab.Type WithPreferredUsername WithIcon @@ -35,7 +100,6 @@ type Accountable interface { WithImage WithSummary WithAttachment - WithSetSummary WithDiscoverable WithURL WithPublicKey @@ -50,15 +114,13 @@ type Accountable interface { } // Statusable represents the minimum activitypub interface for representing a 'status'. -// This interface is fulfilled by: Article, Document, Image, Video, Note, Page, Event, Place, Mention, Profile +// (see: IsStatusable() for types implementing this, though you MUST make sure to check +// the typeName as this bare interface may be implementable by non-Statusable types). type Statusable interface { - WithJSONLDId - WithTypeName + vocab.Type WithSummary - WithSetSummary WithName - WithSetName WithInReplyTo WithPublished WithURL @@ -68,20 +130,40 @@ type Statusable interface { WithSensitive WithConversation WithContent - WithSetContent WithAttachment WithTag WithReplies } -// Attachmentable represents the minimum activitypub interface for representing a 'mediaAttachment'. +// Pollable represents the minimum activitypub interface for representing a 'poll' (it's a subset of a status). +// (see: IsPollable() for types implementing this, though you MUST make sure to check +// the typeName as this bare interface may be implementable by non-Pollable types). +type Pollable interface { + WithOneOf + WithAnyOf + WithEndTime + WithClosed + WithVotersCount + + // base-interface + Statusable +} + +// PollOptionable represents the minimum activitypub interface for representing a poll 'option'. +// (see: IsPollOptionable() for types implementing this). +type PollOptionable interface { + WithTypeName + WithName + WithReplies +} + +// Attachmentable represents the minimum activitypub interface for representing a 'mediaAttachment'. (see: IsAttachmentable). // This interface is fulfilled by: Audio, Document, Image, Video type Attachmentable interface { WithTypeName WithMediaType WithURL WithName - WithSetName WithBlurhash } @@ -160,8 +242,7 @@ type ReplyToable interface { // CollectionPageIterator represents the minimum interface for interacting with a wrapped // CollectionPage or OrderedCollectionPage in order to access both next / prev pages and items. type CollectionPageIterator interface { - WithJSONLDId - WithTypeName + vocab.Type NextPage() WithIRI PrevPage() WithIRI @@ -189,12 +270,14 @@ type Flaggable interface { // WithJSONLDId represents an activity with JSONLDIdProperty. type WithJSONLDId interface { GetJSONLDId() vocab.JSONLDIdProperty + SetJSONLDId(vocab.JSONLDIdProperty) } // WithIRI represents an object (possibly) representable as an IRI. type WithIRI interface { GetIRI() *url.URL IsIRI() bool + SetIRI(*url.URL) } // WithType ... @@ -210,20 +293,18 @@ type WithTypeName interface { // WithPreferredUsername represents an activity with ActivityStreamsPreferredUsernameProperty type WithPreferredUsername interface { GetActivityStreamsPreferredUsername() vocab.ActivityStreamsPreferredUsernameProperty + SetActivityStreamsPreferredUsername(vocab.ActivityStreamsPreferredUsernameProperty) } // WithIcon represents an activity with ActivityStreamsIconProperty type WithIcon interface { GetActivityStreamsIcon() vocab.ActivityStreamsIconProperty + SetActivityStreamsIcon(vocab.ActivityStreamsIconProperty) } // WithName represents an activity with ActivityStreamsNameProperty type WithName interface { GetActivityStreamsName() vocab.ActivityStreamsNameProperty -} - -// WithSetName represents an activity with a settable ActivityStreamsNameProperty -type WithSetName interface { SetActivityStreamsName(vocab.ActivityStreamsNameProperty) } @@ -235,81 +316,91 @@ type WithImage interface { // WithSummary represents an activity with ActivityStreamsSummaryProperty type WithSummary interface { GetActivityStreamsSummary() vocab.ActivityStreamsSummaryProperty -} - -// WithSetSummary represents an activity that can have summary set on it. -type WithSetSummary interface { SetActivityStreamsSummary(vocab.ActivityStreamsSummaryProperty) } // WithDiscoverable represents an activity with TootDiscoverableProperty type WithDiscoverable interface { GetTootDiscoverable() vocab.TootDiscoverableProperty + SetTootDiscoverable(vocab.TootDiscoverableProperty) } // WithURL represents an activity with ActivityStreamsUrlProperty type WithURL interface { GetActivityStreamsUrl() vocab.ActivityStreamsUrlProperty + SetActivityStreamsUrl(vocab.ActivityStreamsUrlProperty) } // WithPublicKey represents an activity with W3IDSecurityV1PublicKeyProperty type WithPublicKey interface { GetW3IDSecurityV1PublicKey() vocab.W3IDSecurityV1PublicKeyProperty + SetW3IDSecurityV1PublicKey(vocab.W3IDSecurityV1PublicKeyProperty) } // WithInbox represents an activity with ActivityStreamsInboxProperty type WithInbox interface { GetActivityStreamsInbox() vocab.ActivityStreamsInboxProperty + SetActivityStreamsInbox(vocab.ActivityStreamsInboxProperty) } // WithOutbox represents an activity with ActivityStreamsOutboxProperty type WithOutbox interface { GetActivityStreamsOutbox() vocab.ActivityStreamsOutboxProperty + SetActivityStreamsOutbox(vocab.ActivityStreamsOutboxProperty) } // WithFollowing represents an activity with ActivityStreamsFollowingProperty type WithFollowing interface { GetActivityStreamsFollowing() vocab.ActivityStreamsFollowingProperty + SetActivityStreamsFollowing(vocab.ActivityStreamsFollowingProperty) } // WithFollowers represents an activity with ActivityStreamsFollowersProperty type WithFollowers interface { GetActivityStreamsFollowers() vocab.ActivityStreamsFollowersProperty + SetActivityStreamsFollowers(vocab.ActivityStreamsFollowersProperty) } // WithFeatured represents an activity with TootFeaturedProperty type WithFeatured interface { GetTootFeatured() vocab.TootFeaturedProperty + SetTootFeatured(vocab.TootFeaturedProperty) } // WithAttributedTo represents an activity with ActivityStreamsAttributedToProperty type WithAttributedTo interface { GetActivityStreamsAttributedTo() vocab.ActivityStreamsAttributedToProperty + SetActivityStreamsAttributedTo(vocab.ActivityStreamsAttributedToProperty) } // WithAttachment represents an activity with ActivityStreamsAttachmentProperty type WithAttachment interface { GetActivityStreamsAttachment() vocab.ActivityStreamsAttachmentProperty + SetActivityStreamsAttachment(vocab.ActivityStreamsAttachmentProperty) } // WithTo represents an activity with ActivityStreamsToProperty type WithTo interface { GetActivityStreamsTo() vocab.ActivityStreamsToProperty + SetActivityStreamsTo(vocab.ActivityStreamsToProperty) } // WithInReplyTo represents an activity with ActivityStreamsInReplyToProperty type WithInReplyTo interface { GetActivityStreamsInReplyTo() vocab.ActivityStreamsInReplyToProperty + SetActivityStreamsInReplyTo(vocab.ActivityStreamsInReplyToProperty) } // WithCC represents an activity with ActivityStreamsCcProperty type WithCC interface { GetActivityStreamsCc() vocab.ActivityStreamsCcProperty + SetActivityStreamsCc(vocab.ActivityStreamsCcProperty) } // WithSensitive represents an activity with ActivityStreamsSensitiveProperty type WithSensitive interface { GetActivityStreamsSensitive() vocab.ActivityStreamsSensitiveProperty + SetActivityStreamsSensitive(vocab.ActivityStreamsSensitiveProperty) } // WithConversation ... @@ -319,36 +410,37 @@ type WithConversation interface { // TODO // WithContent represents an activity with ActivityStreamsContentProperty type WithContent interface { GetActivityStreamsContent() vocab.ActivityStreamsContentProperty -} - -// WithSetContent represents an activity that can have content set on it. -type WithSetContent interface { SetActivityStreamsContent(vocab.ActivityStreamsContentProperty) } // WithPublished represents an activity with ActivityStreamsPublishedProperty type WithPublished interface { GetActivityStreamsPublished() vocab.ActivityStreamsPublishedProperty + SetActivityStreamsPublished(vocab.ActivityStreamsPublishedProperty) } // WithTag represents an activity with ActivityStreamsTagProperty type WithTag interface { GetActivityStreamsTag() vocab.ActivityStreamsTagProperty + SetActivityStreamsTag(vocab.ActivityStreamsTagProperty) } // WithReplies represents an activity with ActivityStreamsRepliesProperty type WithReplies interface { GetActivityStreamsReplies() vocab.ActivityStreamsRepliesProperty + SetActivityStreamsReplies(vocab.ActivityStreamsRepliesProperty) } // WithMediaType represents an activity with ActivityStreamsMediaTypeProperty type WithMediaType interface { GetActivityStreamsMediaType() vocab.ActivityStreamsMediaTypeProperty + SetActivityStreamsMediaType(vocab.ActivityStreamsMediaTypeProperty) } // WithBlurhash represents an activity with TootBlurhashProperty type WithBlurhash interface { GetTootBlurhash() vocab.TootBlurhashProperty + SetTootBlurhash(vocab.TootBlurhashProperty) } // type withFocalPoint interface { @@ -358,44 +450,83 @@ type WithBlurhash interface { // WithHref represents an activity with ActivityStreamsHrefProperty type WithHref interface { GetActivityStreamsHref() vocab.ActivityStreamsHrefProperty + SetActivityStreamsHref(vocab.ActivityStreamsHrefProperty) } // WithUpdated represents an activity with ActivityStreamsUpdatedProperty type WithUpdated interface { GetActivityStreamsUpdated() vocab.ActivityStreamsUpdatedProperty + SetActivityStreamsUpdated(vocab.ActivityStreamsUpdatedProperty) } // WithActor represents an activity with ActivityStreamsActorProperty type WithActor interface { GetActivityStreamsActor() vocab.ActivityStreamsActorProperty + SetActivityStreamsActor(vocab.ActivityStreamsActorProperty) } // WithObject represents an activity with ActivityStreamsObjectProperty type WithObject interface { GetActivityStreamsObject() vocab.ActivityStreamsObjectProperty + SetActivityStreamsObject(vocab.ActivityStreamsObjectProperty) } // WithNext represents an activity with ActivityStreamsNextProperty type WithNext interface { GetActivityStreamsNext() vocab.ActivityStreamsNextProperty + SetActivityStreamsNext(vocab.ActivityStreamsNextProperty) } // WithPartOf represents an activity with ActivityStreamsPartOfProperty type WithPartOf interface { GetActivityStreamsPartOf() vocab.ActivityStreamsPartOfProperty + SetActivityStreamsPartOf(vocab.ActivityStreamsPartOfProperty) } // WithItems represents an activity with ActivityStreamsItemsProperty type WithItems interface { GetActivityStreamsItems() vocab.ActivityStreamsItemsProperty + SetActivityStreamsItems(vocab.ActivityStreamsItemsProperty) } // WithManuallyApprovesFollowers represents a Person or profile with the ManuallyApprovesFollowers property. type WithManuallyApprovesFollowers interface { GetActivityStreamsManuallyApprovesFollowers() vocab.ActivityStreamsManuallyApprovesFollowersProperty + SetActivityStreamsManuallyApprovesFollowers(vocab.ActivityStreamsManuallyApprovesFollowersProperty) } // WithEndpoints represents a Person or profile with the endpoints property type WithEndpoints interface { GetActivityStreamsEndpoints() vocab.ActivityStreamsEndpointsProperty + SetActivityStreamsEndpoints(vocab.ActivityStreamsEndpointsProperty) +} + +// WithOneOf represents an activity with the oneOf property. +type WithOneOf interface { + GetActivityStreamsOneOf() vocab.ActivityStreamsOneOfProperty + SetActivityStreamsOneOf(vocab.ActivityStreamsOneOfProperty) +} + +// WithOneOf represents an activity with the oneOf property. +type WithAnyOf interface { + GetActivityStreamsAnyOf() vocab.ActivityStreamsAnyOfProperty + SetActivityStreamsAnyOf(vocab.ActivityStreamsAnyOfProperty) +} + +// WithEndTime represents an activity with the endTime property. +type WithEndTime interface { + GetActivityStreamsEndTime() vocab.ActivityStreamsEndTimeProperty + SetActivityStreamsEndTime(vocab.ActivityStreamsEndTimeProperty) +} + +// WithClosed represents an activity with the closed property. +type WithClosed interface { + GetActivityStreamsClosed() vocab.ActivityStreamsClosedProperty + SetActivityStreamsClosed(vocab.ActivityStreamsClosedProperty) +} + +// WithVotersCount represents an activity with the votersCount property. +type WithVotersCount interface { + GetTootVotersCount() vocab.TootVotersCountProperty + SetTootVotersCount(vocab.TootVotersCountProperty) } diff --git a/internal/ap/normalize.go b/internal/ap/normalize.go index 8bc2a70e8..52ada2848 100644 --- a/internal/ap/normalize.go +++ b/internal/ap/normalize.go @@ -37,92 +37,62 @@ import ( // The rawActivity map should the freshly deserialized json representation of the Activity. // // This function is a noop if the type passed in is anything except a Create or Update with a Statusable or Accountable as its Object. -func NormalizeIncomingActivityObject(activity pub.Activity, rawJSON map[string]interface{}) { - if typeName := activity.GetTypeName(); typeName != ActivityCreate && typeName != ActivityUpdate { - // Only interested in Create or Update right now. - return - } - - withObject, ok := activity.(WithObject) +func NormalizeIncomingActivity(activity pub.Activity, rawJSON map[string]interface{}) { + // From the activity extract the data vocab.Type + its "raw" JSON. + dataType, rawData, ok := ExtractActivityData(activity, rawJSON) if !ok { - // Create was not a WithObject. return } - createObject := withObject.GetActivityStreamsObject() - if createObject == nil { - // No object set. - return - } - - if createObject.Len() != 1 { - // Not interested in Object arrays. - return - } - - // We now know length is 1 so get the first - // item from the iter. We need this to be - // a Statusable or Accountable if we're to continue. - i := createObject.At(0) - if i == nil { - // This is awkward. - return - } - - t := i.GetType() - if t == nil { - // This is also awkward. - return - } - - switch t.GetTypeName() { - case ObjectArticle, ObjectDocument, ObjectImage, ObjectVideo, ObjectNote, ObjectPage, ObjectEvent, ObjectPlace, ObjectProfile: - statusable, ok := t.(Statusable) + switch dataType.GetTypeName() { + // "Pollable" types. + case ActivityQuestion: + pollable, ok := dataType.(Pollable) if !ok { - // Object is not Statusable; - // we're not interested. return } - rawObject, ok := rawJSON["object"] - if !ok { - // No object in raw map. - return - } + // Normalize the Pollable specific properties. + NormalizeIncomingPollOptions(pollable, rawData) - rawStatusableJSON, ok := rawObject.(map[string]interface{}) + // Fallthrough to handle + // the rest as Statusable. + fallthrough + + // "Statusable" types. + case ObjectArticle, + ObjectDocument, + ObjectImage, + ObjectVideo, + ObjectNote, + ObjectPage, + ObjectEvent, + ObjectPlace, + ObjectProfile: + statusable, ok := dataType.(Statusable) if !ok { - // Object wasn't a json object. return } // Normalize everything we can on the statusable. - NormalizeIncomingContent(statusable, rawStatusableJSON) - NormalizeIncomingAttachments(statusable, rawStatusableJSON) - NormalizeIncomingSummary(statusable, rawStatusableJSON) - NormalizeIncomingName(statusable, rawStatusableJSON) - case ActorApplication, ActorGroup, ActorOrganization, ActorPerson, ActorService: - accountable, ok := t.(Accountable) - if !ok { - // Object is not Accountable; - // we're not interested. - return - } + NormalizeIncomingContent(statusable, rawData) + NormalizeIncomingAttachments(statusable, rawData) + NormalizeIncomingSummary(statusable, rawData) + NormalizeIncomingName(statusable, rawData) - rawObject, ok := rawJSON["object"] + // "Accountable" types. + case ActorApplication, + ActorGroup, + ActorOrganization, + ActorPerson, + ActorService: + accountable, ok := dataType.(Accountable) if !ok { - // No object in raw map. - return - } - - rawAccountableJSON, ok := rawObject.(map[string]interface{}) - if !ok { - // Object wasn't a json object. return } // Normalize everything we can on the accountable. - NormalizeIncomingSummary(accountable, rawAccountableJSON) + NormalizeIncomingSummary(accountable, rawData) } } @@ -132,7 +102,7 @@ func NormalizeIncomingActivityObject(activity pub.Activity, rawJSON map[string]i // // noop if there was no content in the json object map or the // content was not a plain string. -func NormalizeIncomingContent(item WithSetContent, rawJSON map[string]interface{}) { +func NormalizeIncomingContent(item WithContent, rawJSON map[string]interface{}) { rawContent, ok := rawJSON["content"] if !ok { // No content in rawJSON. @@ -228,7 +198,7 @@ func NormalizeIncomingAttachments(item WithAttachment, rawJSON map[string]interf // // noop if there was no summary in the json object map or the // summary was not a plain string. -func NormalizeIncomingSummary(item WithSetSummary, rawJSON map[string]interface{}) { +func NormalizeIncomingSummary(item WithSummary, rawJSON map[string]interface{}) { rawSummary, ok := rawJSON["summary"] if !ok { // No summary in rawJSON. @@ -258,7 +228,7 @@ func NormalizeIncomingSummary(item WithSetSummary, rawJSON map[string]interface{ // // noop if there was no name in the json object map or the // name was not a plain string. -func NormalizeIncomingName(item WithSetName, rawJSON map[string]interface{}) { +func NormalizeIncomingName(item WithName, rawJSON map[string]interface{}) { rawName, ok := rawJSON["name"] if !ok { // No name in rawJSON. @@ -284,3 +254,60 @@ func NormalizeIncomingName(item WithSetName, rawJSON map[string]interface{}) { nameProp.AppendXMLSchemaString(name) item.SetActivityStreamsName(nameProp) } + +// NormalizeIncomingOneOf normalizes all oneOf (if any) of the given +// item, replacing the 'name' field of each oneOf with the raw 'name' +// value from the raw json object map, and doing sanitization +// on the result. +// +// noop if there are no oneOf; noop if oneOf is not expected format. +func NormalizeIncomingPollOptions(item WithOneOf, rawJSON map[string]interface{}) { + var oneOf []interface{} + + // Get the raw one-of JSON data. + rawOneOf, ok := rawJSON["oneOf"] + if !ok { + return + } + + // Convert to slice if not already, so we can iterate. + if oneOf, ok = rawOneOf.([]interface{}); !ok { + oneOf = []interface{}{rawOneOf} + } + + // Extract the one-of property from interface. + oneOfProp := item.GetActivityStreamsOneOf() + if oneOfProp == nil { + return + } + + // Check we have useable one-of JSON-vs-unmarshaled data. + if l := oneOfProp.Len(); l == 0 || l != len(oneOf) { + return + } + + // Get start and end of iter. + start := oneOfProp.Begin() + end := oneOfProp.End() + + // Iterate a counter, from start through to end iter item. + for i, iter := 0, start; iter != end; i, iter = i+1, iter.Next() { + // Get item type. + t := iter.GetType() + + // Check fulfills Choiceable type + // (this accounts for nil input type). + choiceable, ok := t.(PollOptionable) + if !ok { + continue + } + + // Get the corresponding raw one-of data. + rawChoice, ok := oneOf[i].(map[string]interface{}) + if !ok { + continue + } + + NormalizeIncomingName(choiceable, rawChoice) + } +} diff --git a/internal/ap/normalize_test.go b/internal/ap/normalize_test.go index cefaf4d38..cd1affe60 100644 --- a/internal/ap/normalize_test.go +++ b/internal/ap/normalize_test.go @@ -191,7 +191,7 @@ func (suite *NormalizeTestSuite) TestNormalizeActivityObject() { note, ) - ap.NormalizeIncomingActivityObject(create, map[string]interface{}{"object": rawNote}) + ap.NormalizeIncomingActivity(create, map[string]interface{}{"object": rawNote}) suite.Equal(`UPDATE: As of this morning there are now more than 7 million Mastodon users, most from the #TwitterMigration.

In fact, 100,000 new accounts have been created since last night.

Since last night's spike 8,000-12,000 new accounts are being created every hour.

Yesterday, I estimated that Mastodon would have 8 million users by the end of the week. That might happen a lot sooner if this trend continues.`, ap.ExtractContent(note)) } diff --git a/internal/ap/resolve.go b/internal/ap/resolve.go index a9955be3f..61f187da0 100644 --- a/internal/ap/resolve.go +++ b/internal/ap/resolve.go @@ -20,62 +20,134 @@ package ap import ( "context" "encoding/json" + "errors" + "fmt" + "net/http" + "sync" + "github.com/superseriousbusiness/activity/pub" "github.com/superseriousbusiness/activity/streams" - "github.com/superseriousbusiness/activity/streams/vocab" "github.com/superseriousbusiness/gotosocial/internal/gtserror" ) +// mapPool is a memory pool of maps for JSON decoding. +var mapPool = sync.Pool{ + New: func() any { + return make(map[string]any) + }, +} + +// getMap acquires a map from memory pool. +func getMap() map[string]any { + m := mapPool.Get().(map[string]any) //nolint + return m +} + +// putMap clears and places map back in pool. +func putMap(m map[string]any) { + if len(m) > int(^uint8(0)) { + // don't pool overly + // large maps. + return + } + for k := range m { + delete(m, k) + } + mapPool.Put(m) +} + +// ResolveActivity is a util function for pulling a pub.Activity type out of an incoming request body. +func ResolveIncomingActivity(r *http.Request) (pub.Activity, gtserror.WithCode) { + // Get "raw" map + // destination. + raw := getMap() + + // Tidy up when done. + defer r.Body.Close() + + // Decode the JSON body stream into "raw" map. + if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { + err := gtserror.Newf("error decoding json: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Resolve "raw" JSON to vocab.Type. + t, err := streams.ToType(r.Context(), raw) + if err != nil { + if !streams.IsUnmatchedErr(err) { + err := gtserror.Newf("error matching json to type: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Respond with bad request; we just couldn't + // match the type to one that we know about. + const text = "body json not resolvable as ActivityStreams type" + return nil, gtserror.NewErrorBadRequest(errors.New(text), text) + } + + // Ensure this is an Activity type. + activity, ok := t.(pub.Activity) + if !ok { + text := fmt.Sprintf("cannot resolve vocab type %T as pub.Activity", t) + return nil, gtserror.NewErrorBadRequest(errors.New(text), text) + } + + if activity.GetJSONLDId() == nil { + const text = "missing ActivityStreams id property" + return nil, gtserror.NewErrorBadRequest(errors.New(text), text) + } + + // Normalize any Statusable, Accountable, Pollable fields found. + // (see: https://github.com/superseriousbusiness/gotosocial/issues/1661) + NormalizeIncomingActivity(activity, raw) + + // Release. + putMap(raw) + + return activity, nil +} + // ResolveStatusable tries to resolve the given bytes into an ActivityPub Statusable representation. // It will then perform normalization on the Statusable. // -// Works for: Article, Document, Image, Video, Note, Page, Event, Place, Profile +// Works for: Article, Document, Image, Video, Note, Page, Event, Place, Profile, Question. func ResolveStatusable(ctx context.Context, b []byte) (Statusable, error) { - rawStatusable := make(map[string]interface{}) - if err := json.Unmarshal(b, &rawStatusable); err != nil { + // Get "raw" map + // destination. + raw := getMap() + + // Unmarshal the raw JSON data in a "raw" JSON map. + if err := json.Unmarshal(b, &raw); err != nil { return nil, gtserror.Newf("error unmarshalling bytes into json: %w", err) } - t, err := streams.ToType(ctx, rawStatusable) + // Resolve an ActivityStreams type from JSON. + t, err := streams.ToType(ctx, raw) if err != nil { return nil, gtserror.Newf("error resolving json into ap vocab type: %w", err) } - var ( - statusable Statusable - ok bool - ) - - switch t.GetTypeName() { - case ObjectArticle: - statusable, ok = t.(vocab.ActivityStreamsArticle) - case ObjectDocument: - statusable, ok = t.(vocab.ActivityStreamsDocument) - case ObjectImage: - statusable, ok = t.(vocab.ActivityStreamsImage) - case ObjectVideo: - statusable, ok = t.(vocab.ActivityStreamsVideo) - case ObjectNote: - statusable, ok = t.(vocab.ActivityStreamsNote) - case ObjectPage: - statusable, ok = t.(vocab.ActivityStreamsPage) - case ObjectEvent: - statusable, ok = t.(vocab.ActivityStreamsEvent) - case ObjectPlace: - statusable, ok = t.(vocab.ActivityStreamsPlace) - case ObjectProfile: - statusable, ok = t.(vocab.ActivityStreamsProfile) - } - + // Attempt to cast as Statusable. + statusable, ok := ToStatusable(t) if !ok { - err = gtserror.Newf("could not resolve %T to Statusable", t) + err := gtserror.Newf("cannot resolve vocab type %T as statusable", t) return nil, gtserror.SetWrongType(err) } - NormalizeIncomingContent(statusable, rawStatusable) - NormalizeIncomingAttachments(statusable, rawStatusable) - NormalizeIncomingSummary(statusable, rawStatusable) - NormalizeIncomingName(statusable, rawStatusable) + if pollable, ok := ToPollable(statusable); ok { + // Question requires extra normalization, and + // fortunately directly implements Statusable. + NormalizeIncomingPollOptions(pollable, raw) + statusable = pollable + } + + NormalizeIncomingContent(statusable, raw) + NormalizeIncomingAttachments(statusable, raw) + NormalizeIncomingSummary(statusable, raw) + NormalizeIncomingName(statusable, raw) + + // Release. + putMap(raw) return statusable, nil } @@ -85,40 +157,32 @@ func ResolveStatusable(ctx context.Context, b []byte) (Statusable, error) { // // Works for: Application, Group, Organization, Person, Service func ResolveAccountable(ctx context.Context, b []byte) (Accountable, error) { - rawAccountable := make(map[string]interface{}) - if err := json.Unmarshal(b, &rawAccountable); err != nil { + // Get "raw" map + // destination. + raw := getMap() + + // Unmarshal the raw JSON data in a "raw" JSON map. + if err := json.Unmarshal(b, &raw); err != nil { return nil, gtserror.Newf("error unmarshalling bytes into json: %w", err) } - t, err := streams.ToType(ctx, rawAccountable) + // Resolve an ActivityStreams type from JSON. + t, err := streams.ToType(ctx, raw) if err != nil { return nil, gtserror.Newf("error resolving json into ap vocab type: %w", err) } - var ( - accountable Accountable - ok bool - ) - - switch t.GetTypeName() { - case ActorApplication: - accountable, ok = t.(vocab.ActivityStreamsApplication) - case ActorGroup: - accountable, ok = t.(vocab.ActivityStreamsGroup) - case ActorOrganization: - accountable, ok = t.(vocab.ActivityStreamsOrganization) - case ActorPerson: - accountable, ok = t.(vocab.ActivityStreamsPerson) - case ActorService: - accountable, ok = t.(vocab.ActivityStreamsService) - } - + // Attempt to cast as Statusable. + accountable, ok := ToAccountable(t) if !ok { - err = gtserror.Newf("could not resolve %T to Accountable", t) + err := gtserror.Newf("cannot resolve vocab type %T as accountable", t) return nil, gtserror.SetWrongType(err) } - NormalizeIncomingSummary(accountable, rawAccountable) + NormalizeIncomingSummary(accountable, raw) + + // Release. + putMap(raw) return accountable, nil } diff --git a/internal/ap/resolve_test.go b/internal/ap/resolve_test.go index efb56b1c4..5ec1c4234 100644 --- a/internal/ap/resolve_test.go +++ b/internal/ap/resolve_test.go @@ -43,7 +43,7 @@ func (suite *ResolveTestSuite) TestResolveDocumentAsAccountable() { accountable, err := ap.ResolveAccountable(context.Background(), b) suite.True(gtserror.WrongType(err)) - suite.EqualError(err, "ResolveAccountable: could not resolve *typedocument.ActivityStreamsDocument to Accountable") + suite.EqualError(err, "ResolveAccountable: cannot resolve vocab type *typedocument.ActivityStreamsDocument as accountable") suite.Nil(accountable) } diff --git a/internal/api/activitypub/users/inboxpost_test.go b/internal/api/activitypub/users/inboxpost_test.go index d26dae513..7660050df 100644 --- a/internal/api/activitypub/users/inboxpost_test.go +++ b/internal/api/activitypub/users/inboxpost_test.go @@ -486,7 +486,7 @@ func (suite *InboxPostTestSuite) TestPostEmptyCreate() { requestingAccount, targetAccount, http.StatusBadRequest, - `{"error":"Bad Request: incoming Activity Create did not have required id property set"}`, + `{"error":"Bad Request: missing ActivityStreams id property"}`, suite.signatureCheck, ) } @@ -511,7 +511,7 @@ func (suite *InboxPostTestSuite) TestPostFromBlockedAccount() { requestingAccount, targetAccount, http.StatusForbidden, - `{"error":"Forbidden"}`, + `{"error":"Forbidden: blocked"}`, suite.signatureCheck, ) } @@ -555,7 +555,7 @@ func (suite *InboxPostTestSuite) TestPostUnauthorized() { requestingAccount, targetAccount, http.StatusUnauthorized, - `{"error":"Unauthorized"}`, + `{"error":"Unauthorized: not authenticated"}`, // Omit signature check middleware. ) } diff --git a/internal/federation/federatingactor.go b/internal/federation/federatingactor.go index 942aa8198..774fa30af 100644 --- a/internal/federation/federatingactor.go +++ b/internal/federation/federatingactor.go @@ -19,10 +19,8 @@ package federation import ( "context" - "encoding/json" "errors" "fmt" - "io" "net/http" "net/url" "strings" @@ -30,7 +28,6 @@ import ( errorsv2 "codeberg.org/gruf/go-errors/v2" "codeberg.org/gruf/go-kv" "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/db" @@ -132,12 +129,13 @@ func (f *federatingActor) PostInboxScheme(ctx context.Context, w http.ResponseWr // Authenticate request by checking http signature. ctx, authenticated, err := f.sideEffectActor.AuthenticatePostInbox(ctx, w, r) if err != nil { + err := gtserror.Newf("error authenticating post inbox: %w", err) return false, gtserror.NewErrorInternalError(err) } if !authenticated { - err = errors.New("not authenticated") - return false, gtserror.NewErrorUnauthorized(err) + const text = "not authenticated" + return false, gtserror.NewErrorUnauthorized(errors.New(text), text) } /* @@ -146,7 +144,7 @@ func (f *federatingActor) PostInboxScheme(ctx context.Context, w http.ResponseWr */ // Obtain the activity; reject unknown activities. - activity, errWithCode := resolveActivity(ctx, r) + activity, errWithCode := ap.ResolveIncomingActivity(r) if errWithCode != nil { return false, errWithCode } @@ -156,6 +154,7 @@ func (f *federatingActor) PostInboxScheme(ctx context.Context, w http.ResponseWr // involved in it tangentially. ctx, err = f.sideEffectActor.PostInboxRequestBodyHook(ctx, r, activity) if err != nil { + err := gtserror.Newf("error during post inbox request body hook: %w", err) return false, gtserror.NewErrorInternalError(err) } @@ -174,6 +173,7 @@ func (f *federatingActor) PostInboxScheme(ctx context.Context, w http.ResponseWr } // Real error has occurred. + err := gtserror.Newf("error authorizing post inbox: %w", err) return false, gtserror.NewErrorInternalError(err) } @@ -181,8 +181,8 @@ func (f *federatingActor) PostInboxScheme(ctx context.Context, w http.ResponseWr // Block exists either from this instance against // one or more directly involved actors, or between // receiving account and one of those actors. - err = errors.New("blocked") - return false, gtserror.NewErrorForbidden(err) + const text = "blocked" + return false, gtserror.NewErrorForbidden(errors.New(text), text) } // Copy existing URL + add request host and scheme. @@ -205,13 +205,13 @@ func (f *federatingActor) PostInboxScheme(ctx context.Context, w http.ResponseWr // Send the rejection to the peer. if errors.Is(err, pub.ErrObjectRequired) || errors.Is(err, pub.ErrTargetRequired) { // Log the original error but return something a bit more generic. - l.Debugf("malformed incoming Activity: %q", err) - err = errors.New("malformed incoming Activity: an Object and/or Target was required but not set") - return false, gtserror.NewErrorBadRequest(err, err.Error()) + log.Warnf(ctx, "malformed incoming activity: %v", err) + const text = "malformed activity: missing Object and / or Target" + return false, gtserror.NewErrorBadRequest(errors.New(text), text) } // There's been some real error. - err = fmt.Errorf("PostInboxScheme: error calling sideEffectActor.PostInbox: %w", err) + err := gtserror.Newf("error calling sideEffectActor.PostInbox: %w", err) return false, gtserror.NewErrorInternalError(err) } @@ -241,7 +241,7 @@ func (f *federatingActor) PostInboxScheme(ctx context.Context, w http.ResponseWr ) { // Failed inbox forwarding is not a show-stopper, // and doesn't even necessarily denote a real error. - l.Warnf("error calling sideEffectActor.InboxForwarding: %q", err) + l.Warnf("error calling sideEffectActor.InboxForwarding: %v", err) } } @@ -250,58 +250,6 @@ func (f *federatingActor) PostInboxScheme(ctx context.Context, w http.ResponseWr return true, nil } -// resolveActivity is a util function for pulling a -// pub.Activity type out of an incoming POST request. -func resolveActivity(ctx context.Context, r *http.Request) (pub.Activity, gtserror.WithCode) { - // Tidy up when done. - defer r.Body.Close() - - b, err := io.ReadAll(r.Body) - if err != nil { - err = fmt.Errorf("error reading request body: %w", err) - return nil, gtserror.NewErrorInternalError(err) - } - - var rawActivity map[string]interface{} - if err := json.Unmarshal(b, &rawActivity); err != nil { - err = fmt.Errorf("error unmarshalling request body: %w", err) - return nil, gtserror.NewErrorInternalError(err) - } - - t, err := streams.ToType(ctx, rawActivity) - if err != nil { - if !streams.IsUnmatchedErr(err) { - // Real error. - err = fmt.Errorf("error matching json to type: %w", err) - return nil, gtserror.NewErrorInternalError(err) - } - - // Respond with bad request; we just couldn't - // match the type to one that we know about. - err = errors.New("body json could not be resolved to ActivityStreams value") - return nil, gtserror.NewErrorBadRequest(err, err.Error()) - } - - activity, ok := t.(pub.Activity) - if !ok { - err = fmt.Errorf("ActivityStreams value with type %T is not a pub.Activity", t) - return nil, gtserror.NewErrorBadRequest(err, err.Error()) - } - - if activity.GetJSONLDId() == nil { - err = fmt.Errorf("incoming Activity %s did not have required id property set", activity.GetTypeName()) - return nil, gtserror.NewErrorBadRequest(err, err.Error()) - } - - // If activity Object is a Statusable, we'll want to replace the - // parsed `content` value with the value from the raw JSON instead. - // See https://github.com/superseriousbusiness/gotosocial/issues/1661 - // Likewise, if it's an Accountable, we'll normalize some fields on it. - ap.NormalizeIncomingActivityObject(activity, rawActivity) - - return activity, nil -} - /* Functions below are just lightly wrapped versions of the original go-fed federatingActor functions.