[chore] internal/ap: add pollable AS types, code reformatting, general niceties (#2248)

This commit is contained in:
kim 2023-10-03 14:59:30 +01:00 committed by GitHub
parent a1ab2c255a
commit 297b6eeaaa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 559 additions and 224 deletions

View File

@ -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
}
}

View File

@ -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 {

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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 <a class="hashtag" href="https://example.org/tag/twittermigration" rel="tag ugc nofollow noreferrer noopener" target="_blank">#TwitterMigration</a>.<br><br>In fact, 100,000 new accounts have been created since last night.<br><br>Since last night's spike 8,000-12,000 new accounts are being created every hour.<br><br>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))
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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.
)
}

View File

@ -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.