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.