diff --git a/internal/api/s2s/user/userget_test.go b/internal/api/s2s/user/userget_test.go index 0859582f4..b45b01b63 100644 --- a/internal/api/s2s/user/userget_test.go +++ b/internal/api/s2s/user/userget_test.go @@ -145,7 +145,7 @@ func (suite *UserGetTestSuite) TestGetUser() { // convert person to account // since this account is already known, we should get a pretty full model of it from the conversion - a, err := suite.tc.ASPersonToAccount(person) + a, err := suite.tc.ASRepresentationToAccount(person) assert.NoError(suite.T(), err) assert.EqualValues(suite.T(), targetAccount.Username, a.Username) } diff --git a/internal/federation/federatingprotocol.go b/internal/federation/federatingprotocol.go index b30d60350..1764eb791 100644 --- a/internal/federation/federatingprotocol.go +++ b/internal/federation/federatingprotocol.go @@ -130,7 +130,7 @@ func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr return ctx, false, fmt.Errorf("error dereferencing account with public key id %s: %s", publicKeyOwnerURI.String(), err) } - a, err := f.typeConverter.ASPersonToAccount(person) + a, err := f.typeConverter.ASRepresentationToAccount(person) if err != nil { return ctx, false, fmt.Errorf("error converting person with public key id %s to account: %s", publicKeyOwnerURI.String(), err) } diff --git a/internal/federation/federator.go b/internal/federation/federator.go index 7d25b1ea6..4fe0369b9 100644 --- a/internal/federation/federator.go +++ b/internal/federation/federator.go @@ -23,7 +23,6 @@ import ( "net/url" "github.com/go-fed/activity/pub" - "github.com/go-fed/activity/streams/vocab" "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" @@ -38,9 +37,9 @@ type Federator interface { // AuthenticateFederatedRequest can be used to check the authenticity of incoming http-signed requests for federating resources. // The given username will be used to create a transport for making outgoing requests. See the implementation for more detailed comments. AuthenticateFederatedRequest(username string, r *http.Request) (*url.URL, error) - // DereferenceRemoteAccount can be used to get the ActivityStreamsPerson representation of a remote account, based on the account ID (which is a URI). + // DereferenceRemoteAccount can be used to get the representation of a remote account, based on the account ID (which is a URI). // The given username will be used to create a transport for making outgoing requests. See the implementation for more detailed comments. - DereferenceRemoteAccount(username string, remoteAccountID *url.URL) (vocab.ActivityStreamsPerson, error) + DereferenceRemoteAccount(username string, remoteAccountID *url.URL) (typeutils.Accountable, error) // GetTransportForUser returns a new transport initialized with the key credentials belonging to the given username. // This can be used for making signed http requests. GetTransportForUser(username string) (pub.Transport, error) diff --git a/internal/federation/util.go b/internal/federation/util.go index 4adce52b2..ab854db7c 100644 --- a/internal/federation/util.go +++ b/internal/federation/util.go @@ -33,6 +33,7 @@ import ( "github.com/go-fed/activity/streams/vocab" "github.com/go-fed/httpsig" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" ) /* @@ -177,7 +178,7 @@ func (f *federator) AuthenticateFederatedRequest(username string, r *http.Reques return pkOwnerURI, nil } -func (f *federator) DereferenceRemoteAccount(username string, remoteAccountID *url.URL) (vocab.ActivityStreamsPerson, error) { +func (f *federator) DereferenceRemoteAccount(username string, remoteAccountID *url.URL) (typeutils.Accountable, error) { transport, err := f.GetTransportForUser(username) if err != nil { diff --git a/internal/message/fediprocess.go b/internal/message/fediprocess.go index 2402c89bb..6dc6330cf 100644 --- a/internal/message/fediprocess.go +++ b/internal/message/fediprocess.go @@ -46,7 +46,7 @@ func (p *processor) authenticateAndDereferenceFediRequest(username string, r *ht } // convert it to our internal account representation - requestingAccount, err = p.tc.ASPersonToAccount(requestingPerson) + requestingAccount, err = p.tc.ASRepresentationToAccount(requestingPerson) if err != nil { return nil, fmt.Errorf("couldn't convert dereferenced uri %s to gtsmodel account: %s", requestingAccountURI.String(), err) } diff --git a/internal/typeutils/asextractionutil.go b/internal/typeutils/asextractionutil.go index 27d3bc9d1..b69c27881 100644 --- a/internal/typeutils/asextractionutil.go +++ b/internal/typeutils/asextractionutil.go @@ -19,30 +19,100 @@ package typeutils import ( + "crypto/rsa" + "crypto/x509" + "encoding/pem" "errors" + "fmt" "net/url" + "github.com/go-fed/activity/pub" "github.com/go-fed/activity/streams/vocab" - "github.com/superseriousbusiness/gotosocial/internal/media" ) -type usernameable interface { +// Accountable represents the minimum activitypub interface for representing an 'account'. +// This interface is fulfilled by, for example, vocab.ActivityStreamsPerson and vocab.ActivityStreamsApplication +type Accountable interface { + withJSONLDId + withGetTypeName + withPreferredUsername + withIcon + withDisplayName + withImage + withSummary + withDiscoverable + withURL + withPublicKey + withInbox + withOutbox + withFollowing + withFollowers + withFeatured +} + +// all the interfaces below narrow down one particular field of an activity streams object for easy extraction + +type withJSONLDId interface { + GetJSONLDId() vocab.JSONLDIdProperty +} + +type withGetTypeName interface { + GetTypeName() string +} + +type withPreferredUsername interface { GetActivityStreamsPreferredUsername() vocab.ActivityStreamsPreferredUsernameProperty } -type iconable interface { +type withIcon interface { GetActivityStreamsIcon() vocab.ActivityStreamsIconProperty } -type displaynameable interface { +type withDisplayName interface { GetActivityStreamsName() vocab.ActivityStreamsNameProperty } -type imageable interface { +type withImage interface { GetActivityStreamsImage() vocab.ActivityStreamsImageProperty } -func extractPreferredUsername(i usernameable) (string, error) { +type withSummary interface { + GetActivityStreamsSummary() vocab.ActivityStreamsSummaryProperty +} + +type withDiscoverable interface { + GetTootDiscoverable() vocab.TootDiscoverableProperty +} + +type withURL interface { + GetActivityStreamsUrl() vocab.ActivityStreamsUrlProperty +} + +type withPublicKey interface { + GetW3IDSecurityV1PublicKey() vocab.W3IDSecurityV1PublicKeyProperty +} + +type withInbox interface { + GetActivityStreamsInbox() vocab.ActivityStreamsInboxProperty +} + +type withOutbox interface { + GetActivityStreamsOutbox() vocab.ActivityStreamsOutboxProperty +} + +type withFollowing interface { + GetActivityStreamsFollowing() vocab.ActivityStreamsFollowingProperty +} + +type withFollowers interface { + GetActivityStreamsFollowers() vocab.ActivityStreamsFollowersProperty +} + +type withFeatured interface { + GetTootFeatured() vocab.TootFeaturedProperty +} + +func extractPreferredUsername(i withPreferredUsername) (string, error) { u := i.GetActivityStreamsPreferredUsername() if u == nil || !u.IsXMLSchemaString() { return "", errors.New("preferredUsername was not a string") @@ -53,7 +123,7 @@ func extractPreferredUsername(i usernameable) (string, error) { return u.GetXMLSchemaString(), nil } -func extractName(i displaynameable) (string, error) { +func extractName(i withDisplayName) (string, error) { nameProp := i.GetActivityStreamsName() if nameProp == nil { return "", errors.New("activityStreamsName not found") @@ -75,7 +145,7 @@ func extractName(i displaynameable) (string, error) { // "type": "Image", // "url": "http://example.org/path/to/some/file.jpeg" // }, -func extractIconURL(i iconable) (*url.URL, error) { +func extractIconURL(i withIcon) (*url.URL, error) { iconProp := i.GetActivityStreamsIcon() if iconProp == nil { return nil, errors.New("icon property was nil") @@ -84,8 +154,7 @@ func extractIconURL(i iconable) (*url.URL, error) { // icon can potentially contain multiple entries, so we iterate through all of them // here in order to find the first one that meets these criteria: // 1. is an image - // 2. is a supported type - // 3. has a URL so we can grab it + // 2. has a URL so we can grab it for iconIter := iconProp.Begin(); iconIter != iconProp.End(); iconIter = iconIter.Next() { // 1. is an image if !iconIter.IsActivityStreamsImage() { @@ -96,43 +165,23 @@ func extractIconURL(i iconable) (*url.URL, error) { continue } - // 2. is a supported type - imageType := imageValue.GetActivityStreamsMediaType() - if imageType == nil || !media.SupportedImageType(imageType.Get()) { - continue - } - - // 3. has a URL so we can grab it - imageURLProp := imageValue.GetActivityStreamsUrl() - if imageURLProp == nil { - continue - } - - // URL is also an iterable! - // so let's take the first valid one we can find - for urlIter := imageURLProp.Begin(); urlIter != imageURLProp.End(); urlIter = urlIter.Next() { - if !urlIter.IsIRI() { - continue - } - if urlIter.GetIRI() == nil { - continue - } - // found it!!! - return urlIter.GetIRI(), nil + // 2. has a URL so we can grab it + url, err := extractURL(imageValue) + if err == nil && url != nil { + return url, nil } } // if we get to this point we didn't find an icon meeting our criteria :'( return nil, errors.New("could not extract valid image from icon") } - // extractImageURL extracts a URL to a supported image file from something like: // "image": { // "mediaType": "image/jpeg", // "type": "Image", // "url": "http://example.org/path/to/some/file.jpeg" // }, -func extractImageURL(i imageable) (*url.URL, error) { +func extractImageURL(i withImage) (*url.URL, error) { imageProp := i.GetActivityStreamsImage() if imageProp == nil { return nil, errors.New("icon property was nil") @@ -141,8 +190,7 @@ func extractImageURL(i imageable) (*url.URL, error) { // icon can potentially contain multiple entries, so we iterate through all of them // here in order to find the first one that meets these criteria: // 1. is an image - // 2. is a supported type - // 3. has a URL so we can grab it + // 2. has a URL so we can grab it for imageIter := imageProp.Begin(); imageIter != imageProp.End(); imageIter = imageIter.Next() { // 1. is an image if !imageIter.IsActivityStreamsImage() { @@ -153,31 +201,99 @@ func extractImageURL(i imageable) (*url.URL, error) { continue } - // 2. is a supported type - imageType := imageValue.GetActivityStreamsMediaType() - if imageType == nil || !media.SupportedImageType(imageType.Get()) { - continue - } - - // 3. has a URL so we can grab it - imageURLProp := imageValue.GetActivityStreamsUrl() - if imageURLProp == nil { - continue - } - - // URL is also an iterable! - // so let's take the first valid one we can find - for urlIter := imageURLProp.Begin(); urlIter != imageURLProp.End(); urlIter = urlIter.Next() { - if !urlIter.IsIRI() { - continue - } - if urlIter.GetIRI() == nil { - continue - } - // found it!!! - return urlIter.GetIRI(), nil + // 2. has a URL so we can grab it + url, err := extractURL(imageValue) + if err == nil && url != nil { + return url, nil } } // if we get to this point we didn't find an image meeting our criteria :'( return nil, errors.New("could not extract valid image from image property") } + +func extractSummary(i withSummary) (string, error) { + summaryProp := i.GetActivityStreamsSummary() + if summaryProp == nil { + return "", errors.New("summary property was nil") + } + + for summaryIter := summaryProp.Begin(); summaryIter != summaryProp.End(); summaryIter = summaryIter.Next() { + if summaryIter.IsXMLSchemaString() && summaryIter.GetXMLSchemaString() != "" { + return summaryIter.GetXMLSchemaString(), nil + } + } + + return "", errors.New("could not extract summary") +} + +func extractDiscoverable(i withDiscoverable) (bool, error) { + if i.GetTootDiscoverable() == nil { + return false, errors.New("discoverable was nil") + } + return i.GetTootDiscoverable().Get(), nil +} + +func extractURL(i withURL) (*url.URL, error) { + urlProp := i.GetActivityStreamsUrl() + if urlProp == nil { + return nil, errors.New("url property was nil") + } + + for urlIter := urlProp.Begin(); urlIter != urlProp.End(); urlIter = urlIter.Next() { + if urlIter.IsIRI() && urlIter.GetIRI() != nil { + return urlIter.GetIRI(), nil + } + } + + return nil, errors.New("could not extract url") +} + +func extractPublicKeyForOwner(i withPublicKey, forOwner *url.URL) (*rsa.PublicKey, *url.URL, error) { + publicKeyProp := i.GetW3IDSecurityV1PublicKey() + if publicKeyProp == nil { + return nil, nil, errors.New("public key property was nil") + } + + for publicKeyIter := publicKeyProp.Begin(); publicKeyIter != publicKeyProp.End(); publicKeyIter = publicKeyIter.Next() { + pkey := publicKeyIter.Get() + if pkey == nil { + continue + } + + pkeyID, err := pub.GetId(pkey) + if err != nil || pkeyID == nil { + continue + } + + if pkey.GetW3IDSecurityV1Owner() == nil || pkey.GetW3IDSecurityV1Owner().Get() == nil || pkey.GetW3IDSecurityV1Owner().Get().String() != forOwner.String() { + continue + } + + if pkey.GetW3IDSecurityV1PublicKeyPem() == nil { + continue + } + + pkeyPem := pkey.GetW3IDSecurityV1PublicKeyPem().Get() + if pkeyPem == "" { + continue + } + + block, _ := pem.Decode([]byte(pkeyPem)) + if block == nil || block.Type != "PUBLIC KEY" { + return nil, nil, errors.New("could not decode publicKeyPem to PUBLIC KEY pem block type") + } + + p, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, nil, fmt.Errorf("could not parse public key from block bytes: %s", err) + } + if p == nil { + return nil, nil, errors.New("returned public key was empty") + } + + if publicKey, ok := p.(*rsa.PublicKey); ok { + return publicKey, pkeyID, nil + } + } + return nil, nil, errors.New("couldn't find public key") +} diff --git a/internal/typeutils/astointernal.go b/internal/typeutils/astointernal.go index fbecb8af7..ff22d766b 100644 --- a/internal/typeutils/astointernal.go +++ b/internal/typeutils/astointernal.go @@ -22,14 +22,13 @@ import ( "errors" "fmt" - "github.com/go-fed/activity/streams/vocab" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) -func (c *converter) ASPersonToAccount(person vocab.ActivityStreamsPerson) (*gtsmodel.Account, error) { - // first check if we actually already know this person - uriProp := person.GetJSONLDId() +func (c *converter) ASRepresentationToAccount(accountable Accountable) (*gtsmodel.Account, error) { + // first check if we actually already know this account + uriProp := accountable.GetJSONLDId() if uriProp == nil || !uriProp.IsIRI() { return nil, errors.New("no id property found on person, or id was not an iri") } @@ -52,7 +51,7 @@ func (c *converter) ASPersonToAccount(person vocab.ActivityStreamsPerson) (*gtsm // Username aka preferredUsername // We need this one so bail if it's not set. - username, err := extractPreferredUsername(person) + username, err := extractPreferredUsername(accountable) if err != nil { return nil, fmt.Errorf("couldn't extract username: %s", err) } @@ -63,64 +62,103 @@ func (c *converter) ASPersonToAccount(person vocab.ActivityStreamsPerson) (*gtsm // avatar aka icon // if this one isn't extractable in a format we recognise we'll just skip it - if avatarURL, err := extractIconURL(person); err == nil { + if avatarURL, err := extractIconURL(accountable); err == nil { acct.AvatarRemoteURL = avatarURL.String() } // header aka image // if this one isn't extractable in a format we recognise we'll just skip it - if headerURL, err := extractImageURL(person); err == nil { + if headerURL, err := extractImageURL(accountable); err == nil { acct.HeaderRemoteURL = headerURL.String() } // display name aka name // we default to the username, but take the more nuanced name property if it exists acct.DisplayName = username - if displayName, err := extractName(person); err == nil { + if displayName, err := extractName(accountable); err == nil { acct.DisplayName = displayName } - // fields aka attachment array - // TODO + // TODO: fields aka attachment array - // note aka summary - // TODO + // note aka summary + note, err := extractSummary(accountable) + if err == nil && note != "" { + acct.Note = note + } - // bot - // TODO: parse this from application vs. person type + // check for bot and actor type + switch gtsmodel.ActivityStreamsActor(accountable.GetTypeName()) { + case gtsmodel.ActivityStreamsPerson, gtsmodel.ActivityStreamsGroup, gtsmodel.ActivityStreamsOrganization: + // people, groups, and organizations aren't bots + acct.Bot = false + // apps and services are + case gtsmodel.ActivityStreamsApplication, gtsmodel.ActivityStreamsService: + acct.Bot = true + default: + // we don't know what this is! + return nil, fmt.Errorf("type name %s not recognised or not convertible to gtsmodel.ActivityStreamsActor", accountable.GetTypeName()) + } + acct.ActorType = gtsmodel.ActivityStreamsActor(accountable.GetTypeName()) - // locked aka manuallyApprovesFollowers - // TODO + // TODO: locked aka manuallyApprovesFollowers - // discoverable - // TODO + // discoverable + // default to false -- take custom value if it's set though + acct.Discoverable = false + discoverable, err := extractDiscoverable(accountable) + if err == nil { + acct.Discoverable = discoverable + } - // url property - // TODO + // url property + url, err := extractURL(accountable) + if err != nil { + return nil, fmt.Errorf("could not extract url for person with id %s: %s", uri.String(), err) + } + acct.URL = url.String() - // InboxURI - // TODO + // InboxURI + if accountable.GetActivityStreamsInbox() == nil || accountable.GetActivityStreamsInbox().GetIRI() == nil { + return nil, fmt.Errorf("person with id %s had no inbox uri", uri.String()) + } + acct.InboxURI = accountable.GetActivityStreamsInbox().GetIRI().String() - // OutboxURI - // TODO + // OutboxURI + if accountable.GetActivityStreamsOutbox() == nil || accountable.GetActivityStreamsOutbox().GetIRI() == nil { + return nil, fmt.Errorf("person with id %s had no outbox uri", uri.String()) + } + acct.OutboxURI = accountable.GetActivityStreamsOutbox().GetIRI().String() // FollowingURI - // TODO + if accountable.GetActivityStreamsFollowing() == nil || accountable.GetActivityStreamsFollowing().GetIRI() == nil { + return nil, fmt.Errorf("person with id %s had no following uri", uri.String()) + } + acct.FollowingURI = accountable.GetActivityStreamsFollowing().GetIRI().String() // FollowersURI - // TODO + if accountable.GetActivityStreamsFollowers() == nil || accountable.GetActivityStreamsFollowers().GetIRI() == nil { + return nil, fmt.Errorf("person with id %s had no followers uri", uri.String()) + } + acct.FollowersURI = accountable.GetActivityStreamsFollowers().GetIRI().String() - // FeaturedURI - // TODO + // FeaturedURI + // very much optional + if accountable.GetTootFeatured() != nil && accountable.GetTootFeatured().GetIRI() != nil { + acct.FeaturedCollectionURI = accountable.GetTootFeatured().GetIRI().String() + } - // FeaturedTagsURI - // TODO + // TODO: FeaturedTagsURI - // alsoKnownAs - // TODO + // TODO: alsoKnownAs - // publicKey - // TODO + // publicKey + pkey, pkeyURL, err := extractPublicKeyForOwner(accountable, uri) + if err != nil { + return nil, fmt.Errorf("couldn't get public key for person %s: %s", uri.String(), err) + } + acct.PublicKey = pkey + acct.PublicKeyURI = pkeyURL.String() return acct, nil } diff --git a/internal/typeutils/astointernal_test.go b/internal/typeutils/astointernal_test.go index 3f1ba4799..3691ac904 100644 --- a/internal/typeutils/astointernal_test.go +++ b/internal/typeutils/astointernal_test.go @@ -45,11 +45,11 @@ func (suite *ASToInternalTestSuite) SetupTest() { testrig.StandardDBSetup(suite.db) } -func (suite *ASToInternalTestSuite) TestASPersonToAccount() { +func (suite *ASToInternalTestSuite) TestASRepresentationToAccount() { testPerson := suite.people["new_person_1"] - acct, err := suite.typeconverter.ASPersonToAccount(testPerson) + acct, err := suite.typeconverter.ASRepresentationToAccount(testPerson) assert.NoError(suite.T(), err) fmt.Printf("%+v", acct) diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go index af941b83a..5118386a9 100644 --- a/internal/typeutils/converter.go +++ b/internal/typeutils/converter.go @@ -85,8 +85,8 @@ type TypeConverter interface { ACTIVITYSTREAMS MODEL TO INTERNAL (gts) MODEL */ - // ASPersonToAccount converts an activitystreams person into a gts model account - ASPersonToAccount(person vocab.ActivityStreamsPerson) (*gtsmodel.Account, error) + // ASPersonToAccount converts a remote account/person/application representation into a gts model account + ASRepresentationToAccount(accountable Accountable) (*gtsmodel.Account, error) /* INTERNAL (gts) MODEL TO ACTIVITYSTREAMS MODEL diff --git a/internal/typeutils/converter_test.go b/internal/typeutils/converter_test.go index f94541c72..4ca13afcd 100644 --- a/internal/typeutils/converter_test.go +++ b/internal/typeutils/converter_test.go @@ -19,7 +19,6 @@ package typeutils_test import ( - "github.com/go-fed/activity/streams/vocab" "github.com/sirupsen/logrus" "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/config" @@ -34,7 +33,7 @@ type ConverterStandardTestSuite struct { db db.DB log *logrus.Logger accounts map[string]*gtsmodel.Account - people map[string]vocab.ActivityStreamsPerson + people map[string]typeutils.Accountable typeconverter typeutils.TypeConverter } diff --git a/testrig/testmodels.go b/testrig/testmodels.go index 845885ed3..e550c66f7 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -38,6 +38,7 @@ import ( "github.com/go-fed/activity/streams/vocab" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" ) // NewTestTokens returns a map of tokens keyed according to which account the token belongs to. @@ -1050,14 +1051,14 @@ func NewTestActivities(accounts map[string]*gtsmodel.Account) map[string]Activit } // NewTestFediPeople returns a bunch of activity pub Person representations for testing converters and so on. -func NewTestFediPeople() map[string]vocab.ActivityStreamsPerson { +func NewTestFediPeople() map[string]typeutils.Accountable { new_person_1priv, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { panic(err) } new_person_1pub := &new_person_1priv.PublicKey - return map[string]vocab.ActivityStreamsPerson{ + return map[string]typeutils.Accountable{ "new_person_1": newPerson( URLMustParse("https://unknown-instance.com/users/brand_new_person"), URLMustParse("https://unknown-instance.com/users/brand_new_person/following"), @@ -1184,7 +1185,7 @@ func newPerson( avatarURL *url.URL, avatarContentType string, headerURL *url.URL, - headerContentType string) vocab.ActivityStreamsPerson { + headerContentType string) typeutils.Accountable { person := streams.NewActivityStreamsPerson() // id should be the activitypub URI of this user