diff options
author | 2021-05-08 14:25:55 +0200 | |
---|---|---|
committer | 2021-05-08 14:25:55 +0200 | |
commit | 6f5c045284d34ba580d3007f70b97e05d6760527 (patch) | |
tree | 7614da22fba906361a918fb3527465b39272ac93 /internal/typeutils | |
parent | Revert "make boosts work woo (#12)" (#15) (diff) | |
download | gotosocial-6f5c045284d34ba580d3007f70b97e05d6760527.tar.xz |
Ap (#14)
Big restructuring and initial work on activitypub
Diffstat (limited to 'internal/typeutils')
-rw-r--r-- | internal/typeutils/accountable.go | 101 | ||||
-rw-r--r-- | internal/typeutils/asextractionutil.go | 216 | ||||
-rw-r--r-- | internal/typeutils/astointernal.go | 164 | ||||
-rw-r--r-- | internal/typeutils/astointernal_test.go | 206 | ||||
-rw-r--r-- | internal/typeutils/converter.go | 113 | ||||
-rw-r--r-- | internal/typeutils/converter_test.go | 40 | ||||
-rw-r--r-- | internal/typeutils/frontendtointernal.go | 39 | ||||
-rw-r--r-- | internal/typeutils/internaltoas.go | 260 | ||||
-rw-r--r-- | internal/typeutils/internaltoas_test.go | 76 | ||||
-rw-r--r-- | internal/typeutils/internaltofrontend.go | 505 |
10 files changed, 1720 insertions, 0 deletions
diff --git a/internal/typeutils/accountable.go b/internal/typeutils/accountable.go new file mode 100644 index 000000000..ba5c4aa2a --- /dev/null +++ b/internal/typeutils/accountable.go @@ -0,0 +1,101 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package typeutils + +import "github.com/go-fed/activity/streams/vocab" + +// Accountable represents the minimum activitypub interface for representing an 'account'. +// This interface is fulfilled by: Person, Application, Organization, Service, and Group +type Accountable interface { + withJSONLDId + withGetTypeName + withPreferredUsername + withIcon + withDisplayName + withImage + withSummary + withDiscoverable + withURL + withPublicKey + withInbox + withOutbox + withFollowing + withFollowers + withFeatured +} + +type withJSONLDId interface { + GetJSONLDId() vocab.JSONLDIdProperty +} + +type withGetTypeName interface { + GetTypeName() string +} + +type withPreferredUsername interface { + GetActivityStreamsPreferredUsername() vocab.ActivityStreamsPreferredUsernameProperty +} + +type withIcon interface { + GetActivityStreamsIcon() vocab.ActivityStreamsIconProperty +} + +type withDisplayName interface { + GetActivityStreamsName() vocab.ActivityStreamsNameProperty +} + +type withImage interface { + GetActivityStreamsImage() vocab.ActivityStreamsImageProperty +} + +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 +} diff --git a/internal/typeutils/asextractionutil.go b/internal/typeutils/asextractionutil.go new file mode 100644 index 000000000..8d39be3ec --- /dev/null +++ b/internal/typeutils/asextractionutil.go @@ -0,0 +1,216 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package typeutils + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "net/url" + + "github.com/go-fed/activity/pub" +) + +func extractPreferredUsername(i withPreferredUsername) (string, error) { + u := i.GetActivityStreamsPreferredUsername() + if u == nil || !u.IsXMLSchemaString() { + return "", errors.New("preferredUsername was not a string") + } + if u.GetXMLSchemaString() == "" { + return "", errors.New("preferredUsername was empty") + } + return u.GetXMLSchemaString(), nil +} + +func extractName(i withDisplayName) (string, error) { + nameProp := i.GetActivityStreamsName() + if nameProp == nil { + return "", errors.New("activityStreamsName not found") + } + + // take the first name string we can find + for nameIter := nameProp.Begin(); nameIter != nameProp.End(); nameIter = nameIter.Next() { + if nameIter.IsXMLSchemaString() && nameIter.GetXMLSchemaString() != "" { + return nameIter.GetXMLSchemaString(), nil + } + } + + return "", errors.New("activityStreamsName not found") +} + +// extractIconURL extracts a URL to a supported image file from something like: +// "icon": { +// "mediaType": "image/jpeg", +// "type": "Image", +// "url": "http://example.org/path/to/some/file.jpeg" +// }, +func extractIconURL(i withIcon) (*url.URL, error) { + iconProp := i.GetActivityStreamsIcon() + if iconProp == nil { + return nil, errors.New("icon property was nil") + } + + // 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. 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() { + continue + } + imageValue := iconIter.GetActivityStreamsImage() + if imageValue == nil { + continue + } + + // 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 withImage) (*url.URL, error) { + imageProp := i.GetActivityStreamsImage() + if imageProp == nil { + return nil, errors.New("icon property was nil") + } + + // 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. 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() { + continue + } + imageValue := imageIter.GetActivityStreamsImage() + if imageValue == nil { + continue + } + + // 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 new file mode 100644 index 000000000..5e3b6b052 --- /dev/null +++ b/internal/typeutils/astointernal.go @@ -0,0 +1,164 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package typeutils + +import ( + "errors" + "fmt" + + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +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") + } + uri := uriProp.GetIRI() + + acct := >smodel.Account{} + err := c.db.GetWhere("uri", uri.String(), acct) + if err == nil { + // we already know this account so we can skip generating it + return acct, nil + } + if _, ok := err.(db.ErrNoEntries); !ok { + // we don't know the account and there's been a real error + return nil, fmt.Errorf("error getting account with uri %s from the database: %s", uri.String(), err) + } + + // we don't know the account so we need to generate it from the person -- at least we already have the URI! + acct = >smodel.Account{} + acct.URI = uri.String() + + // Username aka preferredUsername + // We need this one so bail if it's not set. + username, err := extractPreferredUsername(accountable) + if err != nil { + return nil, fmt.Errorf("couldn't extract username: %s", err) + } + acct.Username = username + + // Domain + acct.Domain = uri.Host + + // avatar aka icon + // if this one isn't extractable in a format we recognise we'll just skip it + 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(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(accountable); err == nil { + acct.DisplayName = displayName + } + + // TODO: fields aka attachment array + + // note aka summary + note, err := extractSummary(accountable) + if err == nil && note != "" { + acct.Note = note + } + + // 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()) + + // TODO: locked aka manuallyApprovesFollowers + + // 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 + 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 + 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 + 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 + 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 + 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 + // very much optional + if accountable.GetTootFeatured() != nil && accountable.GetTootFeatured().GetIRI() != nil { + acct.FeaturedCollectionURI = accountable.GetTootFeatured().GetIRI().String() + } + + // TODO: FeaturedTagsURI + + // TODO: alsoKnownAs + + // 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 new file mode 100644 index 000000000..1cd66a0ab --- /dev/null +++ b/internal/typeutils/astointernal_test.go @@ -0,0 +1,206 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package typeutils_test + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/go-fed/activity/streams" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type ASToInternalTestSuite struct { + ConverterStandardTestSuite +} + +const ( + gargronAsActivityJson = `{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "toot": "http://joinmastodon.org/ns#", + "featured": { + "@id": "toot:featured", + "@type": "@id" + }, + "featuredTags": { + "@id": "toot:featuredTags", + "@type": "@id" + }, + "alsoKnownAs": { + "@id": "as:alsoKnownAs", + "@type": "@id" + }, + "movedTo": { + "@id": "as:movedTo", + "@type": "@id" + }, + "schema": "http://schema.org#", + "PropertyValue": "schema:PropertyValue", + "value": "schema:value", + "IdentityProof": "toot:IdentityProof", + "discoverable": "toot:discoverable", + "Device": "toot:Device", + "Ed25519Signature": "toot:Ed25519Signature", + "Ed25519Key": "toot:Ed25519Key", + "Curve25519Key": "toot:Curve25519Key", + "EncryptedMessage": "toot:EncryptedMessage", + "publicKeyBase64": "toot:publicKeyBase64", + "deviceId": "toot:deviceId", + "claim": { + "@type": "@id", + "@id": "toot:claim" + }, + "fingerprintKey": { + "@type": "@id", + "@id": "toot:fingerprintKey" + }, + "identityKey": { + "@type": "@id", + "@id": "toot:identityKey" + }, + "devices": { + "@type": "@id", + "@id": "toot:devices" + }, + "messageFranking": "toot:messageFranking", + "messageType": "toot:messageType", + "cipherText": "toot:cipherText", + "suspended": "toot:suspended", + "focalPoint": { + "@container": "@list", + "@id": "toot:focalPoint" + } + } + ], + "id": "https://mastodon.social/users/Gargron", + "type": "Person", + "following": "https://mastodon.social/users/Gargron/following", + "followers": "https://mastodon.social/users/Gargron/followers", + "inbox": "https://mastodon.social/users/Gargron/inbox", + "outbox": "https://mastodon.social/users/Gargron/outbox", + "featured": "https://mastodon.social/users/Gargron/collections/featured", + "featuredTags": "https://mastodon.social/users/Gargron/collections/tags", + "preferredUsername": "Gargron", + "name": "Eugen", + "summary": "<p>Developer of Mastodon and administrator of mastodon.social. I post service announcements, development updates, and personal stuff.</p>", + "url": "https://mastodon.social/@Gargron", + "manuallyApprovesFollowers": false, + "discoverable": true, + "devices": "https://mastodon.social/users/Gargron/collections/devices", + "alsoKnownAs": [ + "https://tooting.ai/users/Gargron" + ], + "publicKey": { + "id": "https://mastodon.social/users/Gargron#main-key", + "owner": "https://mastodon.social/users/Gargron", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvXc4vkECU2/CeuSo1wtn\nFoim94Ne1jBMYxTZ9wm2YTdJq1oiZKif06I2fOqDzY/4q/S9uccrE9Bkajv1dnkO\nVm31QjWlhVpSKynVxEWjVBO5Ienue8gND0xvHIuXf87o61poqjEoepvsQFElA5ym\novljWGSA/jpj7ozygUZhCXtaS2W5AD5tnBQUpcO0lhItYPYTjnmzcc4y2NbJV8hz\n2s2G8qKv8fyimE23gY1XrPJg+cRF+g4PqFXujjlJ7MihD9oqtLGxbu7o1cifTn3x\nBfIdPythWu5b4cujNsB3m3awJjVmx+MHQ9SugkSIYXV0Ina77cTNS0M2PYiH1PFR\nTwIDAQAB\n-----END PUBLIC KEY-----\n" + }, + "tag": [], + "attachment": [ + { + "type": "PropertyValue", + "name": "Patreon", + "value": "<a href=\"https://www.patreon.com/mastodon\" rel=\"me nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://www.</span><span class=\"\">patreon.com/mastodon</span><span class=\"invisible\"></span></a>" + }, + { + "type": "PropertyValue", + "name": "Homepage", + "value": "<a href=\"https://zeonfederated.com\" rel=\"me nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://</span><span class=\"\">zeonfederated.com</span><span class=\"invisible\"></span></a>" + }, + { + "type": "IdentityProof", + "name": "gargron", + "signatureAlgorithm": "keybase", + "signatureValue": "5cfc20c7018f2beefb42a68836da59a792e55daa4d118498c9b1898de7e845690f" + } + ], + "endpoints": { + "sharedInbox": "https://mastodon.social/inbox" + }, + "icon": { + "type": "Image", + "mediaType": "image/jpeg", + "url": "https://files.mastodon.social/accounts/avatars/000/000/001/original/d96d39a0abb45b92.jpg" + }, + "image": { + "type": "Image", + "mediaType": "image/png", + "url": "https://files.mastodon.social/accounts/headers/000/000/001/original/c91b871f294ea63e.png" + } + }` +) + +func (suite *ASToInternalTestSuite) SetupSuite() { + suite.config = testrig.NewTestConfig() + suite.db = testrig.NewTestDB() + suite.log = testrig.NewTestLog() + suite.accounts = testrig.NewTestAccounts() + suite.people = testrig.NewTestFediPeople() + suite.typeconverter = typeutils.NewConverter(suite.config, suite.db) +} + +func (suite *ASToInternalTestSuite) SetupTest() { + testrig.StandardDBSetup(suite.db) +} + +func (suite *ASToInternalTestSuite) TestParsePerson() { + + testPerson := suite.people["new_person_1"] + + acct, err := suite.typeconverter.ASRepresentationToAccount(testPerson) + assert.NoError(suite.T(), err) + + fmt.Printf("%+v", acct) + // TODO: write assertions here, rn we're just eyeballing the output +} + +func (suite *ASToInternalTestSuite) TestParseGargron() { + m := make(map[string]interface{}) + err := json.Unmarshal([]byte(gargronAsActivityJson), &m) + assert.NoError(suite.T(), err) + + t, err := streams.ToType(context.Background(), m) + assert.NoError(suite.T(), err) + + rep, ok := t.(typeutils.Accountable) + assert.True(suite.T(), ok) + + acct, err := suite.typeconverter.ASRepresentationToAccount(rep) + assert.NoError(suite.T(), err) + + fmt.Printf("%+v", acct) + // TODO: write assertions here, rn we're just eyeballing the output +} + +func (suite *ASToInternalTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) +} + +func TestASToInternalTestSuite(t *testing.T) { + suite.Run(t, new(ASToInternalTestSuite)) +} diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go new file mode 100644 index 000000000..5118386a9 --- /dev/null +++ b/internal/typeutils/converter.go @@ -0,0 +1,113 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package typeutils + +import ( + "github.com/go-fed/activity/streams/vocab" + "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +// TypeConverter is an interface for the common action of converting between apimodule (frontend, serializable) models, +// internal gts models used in the database, and activitypub models used in federation. +// +// It requires access to the database because many of the conversions require pulling out database entries and counting them etc. +// That said, it *absolutely should not* manipulate database entries in any way, only examine them. +type TypeConverter interface { + /* + INTERNAL (gts) MODEL TO FRONTEND (mastodon) MODEL + */ + + // AccountToMastoSensitive takes a db model account as a param, and returns a populated mastotype account, or an error + // if something goes wrong. The returned account should be ready to serialize on an API level, and may have sensitive fields, + // so serve it only to an authorized user who should have permission to see it. + AccountToMastoSensitive(account *gtsmodel.Account) (*model.Account, error) + + // AccountToMastoPublic takes a db model account as a param, and returns a populated mastotype account, or an error + // if something goes wrong. The returned account should be ready to serialize on an API level, and may NOT have sensitive fields. + // In other words, this is the public record that the server has of an account. + AccountToMastoPublic(account *gtsmodel.Account) (*model.Account, error) + + // AppToMastoSensitive takes a db model application as a param, and returns a populated mastotype application, or an error + // if something goes wrong. The returned application should be ready to serialize on an API level, and may have sensitive fields + // (such as client id and client secret), so serve it only to an authorized user who should have permission to see it. + AppToMastoSensitive(application *gtsmodel.Application) (*model.Application, error) + + // AppToMastoPublic takes a db model application as a param, and returns a populated mastotype application, or an error + // if something goes wrong. The returned application should be ready to serialize on an API level, and has sensitive + // fields sanitized so that it can be served to non-authorized accounts without revealing any private information. + AppToMastoPublic(application *gtsmodel.Application) (*model.Application, error) + + // AttachmentToMasto converts a gts model media attacahment into its mastodon representation for serialization on the API. + AttachmentToMasto(attachment *gtsmodel.MediaAttachment) (model.Attachment, error) + + // MentionToMasto converts a gts model mention into its mastodon (frontend) representation for serialization on the API. + MentionToMasto(m *gtsmodel.Mention) (model.Mention, error) + + // EmojiToMasto converts a gts model emoji into its mastodon (frontend) representation for serialization on the API. + EmojiToMasto(e *gtsmodel.Emoji) (model.Emoji, error) + + // TagToMasto converts a gts model tag into its mastodon (frontend) representation for serialization on the API. + TagToMasto(t *gtsmodel.Tag) (model.Tag, error) + + // StatusToMasto converts a gts model status into its mastodon (frontend) representation for serialization on the API. + StatusToMasto(s *gtsmodel.Status, targetAccount *gtsmodel.Account, requestingAccount *gtsmodel.Account, boostOfAccount *gtsmodel.Account, replyToAccount *gtsmodel.Account, reblogOfStatus *gtsmodel.Status) (*model.Status, error) + + // VisToMasto converts a gts visibility into its mastodon equivalent + VisToMasto(m gtsmodel.Visibility) model.Visibility + + /* + FRONTEND (mastodon) MODEL TO INTERNAL (gts) MODEL + */ + + // MastoVisToVis converts a mastodon visibility into its gts equivalent. + MastoVisToVis(m model.Visibility) gtsmodel.Visibility + + /* + ACTIVITYSTREAMS MODEL TO INTERNAL (gts) MODEL + */ + + // 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 + */ + + // AccountToAS converts a gts model account into an activity streams person, suitable for federation + AccountToAS(a *gtsmodel.Account) (vocab.ActivityStreamsPerson, error) + + // StatusToAS converts a gts model status into an activity streams note, suitable for federation + StatusToAS(s *gtsmodel.Status) (vocab.ActivityStreamsNote, error) +} + +type converter struct { + config *config.Config + db db.DB +} + +// NewConverter returns a new Converter +func NewConverter(config *config.Config, db db.DB) TypeConverter { + return &converter{ + config: config, + db: db, + } +} diff --git a/internal/typeutils/converter_test.go b/internal/typeutils/converter_test.go new file mode 100644 index 000000000..b2272f50c --- /dev/null +++ b/internal/typeutils/converter_test.go @@ -0,0 +1,40 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package typeutils_test + +import ( + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +// nolint +type ConverterStandardTestSuite struct { + suite.Suite + config *config.Config + db db.DB + log *logrus.Logger + accounts map[string]*gtsmodel.Account + people map[string]typeutils.Accountable + + typeconverter typeutils.TypeConverter +} diff --git a/internal/typeutils/frontendtointernal.go b/internal/typeutils/frontendtointernal.go new file mode 100644 index 000000000..6bb45d61b --- /dev/null +++ b/internal/typeutils/frontendtointernal.go @@ -0,0 +1,39 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package typeutils + +import ( + "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +// MastoVisToVis converts a mastodon visibility into its gts equivalent. +func (c *converter) MastoVisToVis(m model.Visibility) gtsmodel.Visibility { + switch m { + case model.VisibilityPublic: + return gtsmodel.VisibilityPublic + case model.VisibilityUnlisted: + return gtsmodel.VisibilityUnlocked + case model.VisibilityPrivate: + return gtsmodel.VisibilityFollowersOnly + case model.VisibilityDirect: + return gtsmodel.VisibilityDirect + } + return "" +} diff --git a/internal/typeutils/internaltoas.go b/internal/typeutils/internaltoas.go new file mode 100644 index 000000000..73c121155 --- /dev/null +++ b/internal/typeutils/internaltoas.go @@ -0,0 +1,260 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package typeutils + +import ( + "crypto/x509" + "encoding/pem" + "net/url" + + "github.com/go-fed/activity/streams" + "github.com/go-fed/activity/streams/vocab" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +// Converts a gts model account into an Activity Streams person type, following +// the spec laid out for mastodon here: https://docs.joinmastodon.org/spec/activitypub/ +func (c *converter) AccountToAS(a *gtsmodel.Account) (vocab.ActivityStreamsPerson, error) { + person := streams.NewActivityStreamsPerson() + + // id should be the activitypub URI of this user + // something like https://example.org/users/example_user + profileIDURI, err := url.Parse(a.URI) + if err != nil { + return nil, err + } + idProp := streams.NewJSONLDIdProperty() + idProp.SetIRI(profileIDURI) + person.SetJSONLDId(idProp) + + // following + // The URI for retrieving a list of accounts this user is following + followingURI, err := url.Parse(a.FollowingURI) + if err != nil { + return nil, err + } + followingProp := streams.NewActivityStreamsFollowingProperty() + followingProp.SetIRI(followingURI) + person.SetActivityStreamsFollowing(followingProp) + + // followers + // The URI for retrieving a list of this user's followers + followersURI, err := url.Parse(a.FollowersURI) + if err != nil { + return nil, err + } + followersProp := streams.NewActivityStreamsFollowersProperty() + followersProp.SetIRI(followersURI) + person.SetActivityStreamsFollowers(followersProp) + + // inbox + // the activitypub inbox of this user for accepting messages + inboxURI, err := url.Parse(a.InboxURI) + if err != nil { + return nil, err + } + inboxProp := streams.NewActivityStreamsInboxProperty() + inboxProp.SetIRI(inboxURI) + person.SetActivityStreamsInbox(inboxProp) + + // outbox + // the activitypub outbox of this user for serving messages + outboxURI, err := url.Parse(a.OutboxURI) + if err != nil { + return nil, err + } + outboxProp := streams.NewActivityStreamsOutboxProperty() + outboxProp.SetIRI(outboxURI) + person.SetActivityStreamsOutbox(outboxProp) + + // featured posts + // Pinned posts. + featuredURI, err := url.Parse(a.FeaturedCollectionURI) + if err != nil { + return nil, err + } + featuredProp := streams.NewTootFeaturedProperty() + featuredProp.SetIRI(featuredURI) + person.SetTootFeatured(featuredProp) + + // featuredTags + // NOT IMPLEMENTED + + // preferredUsername + // Used for Webfinger lookup. Must be unique on the domain, and must correspond to a Webfinger acct: URI. + preferredUsernameProp := streams.NewActivityStreamsPreferredUsernameProperty() + preferredUsernameProp.SetXMLSchemaString(a.Username) + person.SetActivityStreamsPreferredUsername(preferredUsernameProp) + + // name + // Used as profile display name. + nameProp := streams.NewActivityStreamsNameProperty() + if a.Username != "" { + nameProp.AppendXMLSchemaString(a.DisplayName) + } else { + nameProp.AppendXMLSchemaString(a.Username) + } + person.SetActivityStreamsName(nameProp) + + // summary + // Used as profile bio. + if a.Note != "" { + summaryProp := streams.NewActivityStreamsSummaryProperty() + summaryProp.AppendXMLSchemaString(a.Note) + person.SetActivityStreamsSummary(summaryProp) + } + + // url + // Used as profile link. + profileURL, err := url.Parse(a.URL) + if err != nil { + return nil, err + } + urlProp := streams.NewActivityStreamsUrlProperty() + urlProp.AppendIRI(profileURL) + person.SetActivityStreamsUrl(urlProp) + + // manuallyApprovesFollowers + // Will be shown as a locked account. + // TODO: NOT IMPLEMENTED **YET** -- this needs to be added as an activitypub extension to https://github.com/go-fed/activity, see https://github.com/go-fed/activity/tree/master/astool + + // discoverable + // Will be shown in the profile directory. + discoverableProp := streams.NewTootDiscoverableProperty() + discoverableProp.Set(a.Discoverable) + person.SetTootDiscoverable(discoverableProp) + + // devices + // NOT IMPLEMENTED, probably won't implement + + // alsoKnownAs + // Required for Move activity. + // TODO: NOT IMPLEMENTED **YET** -- this needs to be added as an activitypub extension to https://github.com/go-fed/activity, see https://github.com/go-fed/activity/tree/master/astool + + // publicKey + // Required for signatures. + publicKeyProp := streams.NewW3IDSecurityV1PublicKeyProperty() + + // create the public key + publicKey := streams.NewW3IDSecurityV1PublicKey() + + // set ID for the public key + publicKeyIDProp := streams.NewJSONLDIdProperty() + publicKeyURI, err := url.Parse(a.PublicKeyURI) + if err != nil { + return nil, err + } + publicKeyIDProp.SetIRI(publicKeyURI) + publicKey.SetJSONLDId(publicKeyIDProp) + + // set owner for the public key + publicKeyOwnerProp := streams.NewW3IDSecurityV1OwnerProperty() + publicKeyOwnerProp.SetIRI(profileIDURI) + publicKey.SetW3IDSecurityV1Owner(publicKeyOwnerProp) + + // set the pem key itself + encodedPublicKey, err := x509.MarshalPKIXPublicKey(a.PublicKey) + if err != nil { + return nil, err + } + publicKeyBytes := pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: encodedPublicKey, + }) + publicKeyPEMProp := streams.NewW3IDSecurityV1PublicKeyPemProperty() + publicKeyPEMProp.Set(string(publicKeyBytes)) + publicKey.SetW3IDSecurityV1PublicKeyPem(publicKeyPEMProp) + + // append the public key to the public key property + publicKeyProp.AppendW3IDSecurityV1PublicKey(publicKey) + + // set the public key property on the Person + person.SetW3IDSecurityV1PublicKey(publicKeyProp) + + // tag + // TODO: Any tags used in the summary of this profile + + // attachment + // Used for profile fields. + // TODO: The PropertyValue type has to be added: https://schema.org/PropertyValue + + // endpoints + // NOT IMPLEMENTED -- this is for shared inbox which we don't use + + // icon + // Used as profile avatar. + if a.AvatarMediaAttachmentID != "" { + iconProperty := streams.NewActivityStreamsIconProperty() + + iconImage := streams.NewActivityStreamsImage() + + avatar := >smodel.MediaAttachment{} + if err := c.db.GetByID(a.AvatarMediaAttachmentID, avatar); err != nil { + return nil, err + } + + mediaType := streams.NewActivityStreamsMediaTypeProperty() + mediaType.Set(avatar.File.ContentType) + iconImage.SetActivityStreamsMediaType(mediaType) + + avatarURLProperty := streams.NewActivityStreamsUrlProperty() + avatarURL, err := url.Parse(avatar.URL) + if err != nil { + return nil, err + } + avatarURLProperty.AppendIRI(avatarURL) + iconImage.SetActivityStreamsUrl(avatarURLProperty) + + iconProperty.AppendActivityStreamsImage(iconImage) + person.SetActivityStreamsIcon(iconProperty) + } + + // image + // Used as profile header. + if a.HeaderMediaAttachmentID != "" { + headerProperty := streams.NewActivityStreamsImageProperty() + + headerImage := streams.NewActivityStreamsImage() + + header := >smodel.MediaAttachment{} + if err := c.db.GetByID(a.HeaderMediaAttachmentID, header); err != nil { + return nil, err + } + + mediaType := streams.NewActivityStreamsMediaTypeProperty() + mediaType.Set(header.File.ContentType) + headerImage.SetActivityStreamsMediaType(mediaType) + + headerURLProperty := streams.NewActivityStreamsUrlProperty() + headerURL, err := url.Parse(header.URL) + if err != nil { + return nil, err + } + headerURLProperty.AppendIRI(headerURL) + headerImage.SetActivityStreamsUrl(headerURLProperty) + + headerProperty.AppendActivityStreamsImage(headerImage) + } + + return person, nil +} + +func (c *converter) StatusToAS(s *gtsmodel.Status) (vocab.ActivityStreamsNote, error) { + return nil, nil +} diff --git a/internal/typeutils/internaltoas_test.go b/internal/typeutils/internaltoas_test.go new file mode 100644 index 000000000..8eb827e35 --- /dev/null +++ b/internal/typeutils/internaltoas_test.go @@ -0,0 +1,76 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package typeutils_test + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/go-fed/activity/streams" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type InternalToASTestSuite struct { + ConverterStandardTestSuite +} + +// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout +func (suite *InternalToASTestSuite) SetupSuite() { + // setup standard items + suite.config = testrig.NewTestConfig() + suite.db = testrig.NewTestDB() + suite.log = testrig.NewTestLog() + suite.accounts = testrig.NewTestAccounts() + suite.people = testrig.NewTestFediPeople() + suite.typeconverter = typeutils.NewConverter(suite.config, suite.db) +} + +func (suite *InternalToASTestSuite) SetupTest() { + testrig.StandardDBSetup(suite.db) +} + +// TearDownTest drops tables to make sure there's no data in the db +func (suite *InternalToASTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) +} + +func (suite *InternalToASTestSuite) TestAccountToAS() { + testAccount := suite.accounts["local_account_1"] // take zork for this test + + asPerson, err := suite.typeconverter.AccountToAS(testAccount) + assert.NoError(suite.T(), err) + + ser, err := streams.Serialize(asPerson) + assert.NoError(suite.T(), err) + + bytes, err := json.Marshal(ser) + assert.NoError(suite.T(), err) + + fmt.Println(string(bytes)) + // TODO: write assertions here, rn we're just eyeballing the output +} + +func TestInternalToASTestSuite(t *testing.T) { + suite.Run(t, new(InternalToASTestSuite)) +} diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go new file mode 100644 index 000000000..9456ef531 --- /dev/null +++ b/internal/typeutils/internaltofrontend.go @@ -0,0 +1,505 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package typeutils + +import ( + "fmt" + "time" + + "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (c *converter) AccountToMastoSensitive(a *gtsmodel.Account) (*model.Account, error) { + // we can build this sensitive account easily by first getting the public account.... + mastoAccount, err := c.AccountToMastoPublic(a) + if err != nil { + return nil, err + } + + // then adding the Source object to it... + + // check pending follow requests aimed at this account + fr := []gtsmodel.FollowRequest{} + if err := c.db.GetFollowRequestsForAccountID(a.ID, &fr); err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + return nil, fmt.Errorf("error getting follow requests: %s", err) + } + } + var frc int + if fr != nil { + frc = len(fr) + } + + mastoAccount.Source = &model.Source{ + Privacy: c.VisToMasto(a.Privacy), + Sensitive: a.Sensitive, + Language: a.Language, + Note: a.Note, + Fields: mastoAccount.Fields, + FollowRequestsCount: frc, + } + + return mastoAccount, nil +} + +func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*model.Account, error) { + // count followers + followers := []gtsmodel.Follow{} + if err := c.db.GetFollowersByAccountID(a.ID, &followers); err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + return nil, fmt.Errorf("error getting followers: %s", err) + } + } + var followersCount int + if followers != nil { + followersCount = len(followers) + } + + // count following + following := []gtsmodel.Follow{} + if err := c.db.GetFollowingByAccountID(a.ID, &following); err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + return nil, fmt.Errorf("error getting following: %s", err) + } + } + var followingCount int + if following != nil { + followingCount = len(following) + } + + // count statuses + statuses := []gtsmodel.Status{} + if err := c.db.GetStatusesByAccountID(a.ID, &statuses); err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + return nil, fmt.Errorf("error getting last statuses: %s", err) + } + } + var statusesCount int + if statuses != nil { + statusesCount = len(statuses) + } + + // check when the last status was + lastStatus := >smodel.Status{} + if err := c.db.GetLastStatusForAccountID(a.ID, lastStatus); err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + return nil, fmt.Errorf("error getting last status: %s", err) + } + } + var lastStatusAt string + if lastStatus != nil { + lastStatusAt = lastStatus.CreatedAt.Format(time.RFC3339) + } + + // build the avatar and header URLs + avi := >smodel.MediaAttachment{} + if err := c.db.GetAvatarForAccountID(avi, a.ID); err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + return nil, fmt.Errorf("error getting avatar: %s", err) + } + } + aviURL := avi.URL + aviURLStatic := avi.Thumbnail.URL + + header := >smodel.MediaAttachment{} + if err := c.db.GetHeaderForAccountID(header, a.ID); err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + return nil, fmt.Errorf("error getting header: %s", err) + } + } + headerURL := header.URL + headerURLStatic := header.Thumbnail.URL + + // get the fields set on this account + fields := []model.Field{} + for _, f := range a.Fields { + mField := model.Field{ + Name: f.Name, + Value: f.Value, + } + if !f.VerifiedAt.IsZero() { + mField.VerifiedAt = f.VerifiedAt.Format(time.RFC3339) + } + fields = append(fields, mField) + } + + var acct string + if a.Domain != "" { + // this is a remote user + acct = fmt.Sprintf("%s@%s", a.Username, a.Domain) + } else { + // this is a local user + acct = a.Username + } + + return &model.Account{ + ID: a.ID, + Username: a.Username, + Acct: acct, + DisplayName: a.DisplayName, + Locked: a.Locked, + Bot: a.Bot, + CreatedAt: a.CreatedAt.Format(time.RFC3339), + Note: a.Note, + URL: a.URL, + Avatar: aviURL, + AvatarStatic: aviURLStatic, + Header: headerURL, + HeaderStatic: headerURLStatic, + FollowersCount: followersCount, + FollowingCount: followingCount, + StatusesCount: statusesCount, + LastStatusAt: lastStatusAt, + Emojis: nil, // TODO: implement this + Fields: fields, + }, nil +} + +func (c *converter) AppToMastoSensitive(a *gtsmodel.Application) (*model.Application, error) { + return &model.Application{ + ID: a.ID, + Name: a.Name, + Website: a.Website, + RedirectURI: a.RedirectURI, + ClientID: a.ClientID, + ClientSecret: a.ClientSecret, + VapidKey: a.VapidKey, + }, nil +} + +func (c *converter) AppToMastoPublic(a *gtsmodel.Application) (*model.Application, error) { + return &model.Application{ + Name: a.Name, + Website: a.Website, + }, nil +} + +func (c *converter) AttachmentToMasto(a *gtsmodel.MediaAttachment) (model.Attachment, error) { + return model.Attachment{ + ID: a.ID, + Type: string(a.Type), + URL: a.URL, + PreviewURL: a.Thumbnail.URL, + RemoteURL: a.RemoteURL, + PreviewRemoteURL: a.Thumbnail.RemoteURL, + Meta: model.MediaMeta{ + Original: model.MediaDimensions{ + Width: a.FileMeta.Original.Width, + Height: a.FileMeta.Original.Height, + Size: fmt.Sprintf("%dx%d", a.FileMeta.Original.Width, a.FileMeta.Original.Height), + Aspect: float32(a.FileMeta.Original.Aspect), + }, + Small: model.MediaDimensions{ + Width: a.FileMeta.Small.Width, + Height: a.FileMeta.Small.Height, + Size: fmt.Sprintf("%dx%d", a.FileMeta.Small.Width, a.FileMeta.Small.Height), + Aspect: float32(a.FileMeta.Small.Aspect), + }, + Focus: model.MediaFocus{ + X: a.FileMeta.Focus.X, + Y: a.FileMeta.Focus.Y, + }, + }, + Description: a.Description, + Blurhash: a.Blurhash, + }, nil +} + +func (c *converter) MentionToMasto(m *gtsmodel.Mention) (model.Mention, error) { + target := >smodel.Account{} + if err := c.db.GetByID(m.TargetAccountID, target); err != nil { + return model.Mention{}, err + } + + var local bool + if target.Domain == "" { + local = true + } + + var acct string + if local { + acct = fmt.Sprintf("@%s", target.Username) + } else { + acct = fmt.Sprintf("@%s@%s", target.Username, target.Domain) + } + + return model.Mention{ + ID: target.ID, + Username: target.Username, + URL: target.URL, + Acct: acct, + }, nil +} + +func (c *converter) EmojiToMasto(e *gtsmodel.Emoji) (model.Emoji, error) { + return model.Emoji{ + Shortcode: e.Shortcode, + URL: e.ImageURL, + StaticURL: e.ImageStaticURL, + VisibleInPicker: e.VisibleInPicker, + Category: e.CategoryID, + }, nil +} + +func (c *converter) TagToMasto(t *gtsmodel.Tag) (model.Tag, error) { + tagURL := fmt.Sprintf("%s://%s/tags/%s", c.config.Protocol, c.config.Host, t.Name) + + return model.Tag{ + Name: t.Name, + URL: tagURL, // we don't serve URLs with collections of tagged statuses (FOR NOW) so this is purely for mastodon compatibility ¯\_(ツ)_/¯ + }, nil +} + +func (c *converter) StatusToMasto( + s *gtsmodel.Status, + targetAccount *gtsmodel.Account, + requestingAccount *gtsmodel.Account, + boostOfAccount *gtsmodel.Account, + replyToAccount *gtsmodel.Account, + reblogOfStatus *gtsmodel.Status) (*model.Status, error) { + + repliesCount, err := c.db.GetReplyCountForStatus(s) + if err != nil { + return nil, fmt.Errorf("error counting replies: %s", err) + } + + reblogsCount, err := c.db.GetReblogCountForStatus(s) + if err != nil { + return nil, fmt.Errorf("error counting reblogs: %s", err) + } + + favesCount, err := c.db.GetFaveCountForStatus(s) + if err != nil { + return nil, fmt.Errorf("error counting faves: %s", err) + } + + var faved bool + var reblogged bool + var bookmarked bool + var pinned bool + var muted bool + + // requestingAccount will be nil for public requests without auth + // But if it's not nil, we can also get information about the requestingAccount's interaction with this status + if requestingAccount != nil { + faved, err = c.db.StatusFavedBy(s, requestingAccount.ID) + if err != nil { + return nil, fmt.Errorf("error checking if requesting account has faved status: %s", err) + } + + reblogged, err = c.db.StatusRebloggedBy(s, requestingAccount.ID) + if err != nil { + return nil, fmt.Errorf("error checking if requesting account has reblogged status: %s", err) + } + + muted, err = c.db.StatusMutedBy(s, requestingAccount.ID) + if err != nil { + return nil, fmt.Errorf("error checking if requesting account has muted status: %s", err) + } + + bookmarked, err = c.db.StatusBookmarkedBy(s, requestingAccount.ID) + if err != nil { + return nil, fmt.Errorf("error checking if requesting account has bookmarked status: %s", err) + } + + pinned, err = c.db.StatusPinnedBy(s, requestingAccount.ID) + if err != nil { + return nil, fmt.Errorf("error checking if requesting account has pinned status: %s", err) + } + } + + var mastoRebloggedStatus *model.Status // TODO + + var mastoApplication *model.Application + if s.CreatedWithApplicationID != "" { + gtsApplication := >smodel.Application{} + if err := c.db.GetByID(s.CreatedWithApplicationID, gtsApplication); err != nil { + return nil, fmt.Errorf("error fetching application used to create status: %s", err) + } + mastoApplication, err = c.AppToMastoPublic(gtsApplication) + if err != nil { + return nil, fmt.Errorf("error parsing application used to create status: %s", err) + } + } + + mastoTargetAccount, err := c.AccountToMastoPublic(targetAccount) + if err != nil { + return nil, fmt.Errorf("error parsing account of status author: %s", err) + } + + mastoAttachments := []model.Attachment{} + // the status might already have some gts attachments on it if it's not been pulled directly from the database + // if so, we can directly convert the gts attachments into masto ones + if s.GTSMediaAttachments != nil { + for _, gtsAttachment := range s.GTSMediaAttachments { + mastoAttachment, err := c.AttachmentToMasto(gtsAttachment) + if err != nil { + return nil, fmt.Errorf("error converting attachment with id %s: %s", gtsAttachment.ID, err) + } + mastoAttachments = append(mastoAttachments, mastoAttachment) + } + // the status doesn't have gts attachments on it, but it does have attachment IDs + // in this case, we need to pull the gts attachments from the db to convert them into masto ones + } else { + for _, a := range s.Attachments { + gtsAttachment := >smodel.MediaAttachment{} + if err := c.db.GetByID(a, gtsAttachment); err != nil { + return nil, fmt.Errorf("error getting attachment with id %s: %s", a, err) + } + mastoAttachment, err := c.AttachmentToMasto(gtsAttachment) + if err != nil { + return nil, fmt.Errorf("error converting attachment with id %s: %s", a, err) + } + mastoAttachments = append(mastoAttachments, mastoAttachment) + } + } + + mastoMentions := []model.Mention{} + // the status might already have some gts mentions on it if it's not been pulled directly from the database + // if so, we can directly convert the gts mentions into masto ones + if s.GTSMentions != nil { + for _, gtsMention := range s.GTSMentions { + mastoMention, err := c.MentionToMasto(gtsMention) + if err != nil { + return nil, fmt.Errorf("error converting mention with id %s: %s", gtsMention.ID, err) + } + mastoMentions = append(mastoMentions, mastoMention) + } + // the status doesn't have gts mentions on it, but it does have mention IDs + // in this case, we need to pull the gts mentions from the db to convert them into masto ones + } else { + for _, m := range s.Mentions { + gtsMention := >smodel.Mention{} + if err := c.db.GetByID(m, gtsMention); err != nil { + return nil, fmt.Errorf("error getting mention with id %s: %s", m, err) + } + mastoMention, err := c.MentionToMasto(gtsMention) + if err != nil { + return nil, fmt.Errorf("error converting mention with id %s: %s", gtsMention.ID, err) + } + mastoMentions = append(mastoMentions, mastoMention) + } + } + + mastoTags := []model.Tag{} + // the status might already have some gts tags on it if it's not been pulled directly from the database + // if so, we can directly convert the gts tags into masto ones + if s.GTSTags != nil { + for _, gtsTag := range s.GTSTags { + mastoTag, err := c.TagToMasto(gtsTag) + if err != nil { + return nil, fmt.Errorf("error converting tag with id %s: %s", gtsTag.ID, err) + } + mastoTags = append(mastoTags, mastoTag) + } + // the status doesn't have gts tags on it, but it does have tag IDs + // in this case, we need to pull the gts tags from the db to convert them into masto ones + } else { + for _, t := range s.Tags { + gtsTag := >smodel.Tag{} + if err := c.db.GetByID(t, gtsTag); err != nil { + return nil, fmt.Errorf("error getting tag with id %s: %s", t, err) + } + mastoTag, err := c.TagToMasto(gtsTag) + if err != nil { + return nil, fmt.Errorf("error converting tag with id %s: %s", gtsTag.ID, err) + } + mastoTags = append(mastoTags, mastoTag) + } + } + + mastoEmojis := []model.Emoji{} + // the status might already have some gts emojis on it if it's not been pulled directly from the database + // if so, we can directly convert the gts emojis into masto ones + if s.GTSEmojis != nil { + for _, gtsEmoji := range s.GTSEmojis { + mastoEmoji, err := c.EmojiToMasto(gtsEmoji) + if err != nil { + return nil, fmt.Errorf("error converting emoji with id %s: %s", gtsEmoji.ID, err) + } + mastoEmojis = append(mastoEmojis, mastoEmoji) + } + // the status doesn't have gts emojis on it, but it does have emoji IDs + // in this case, we need to pull the gts emojis from the db to convert them into masto ones + } else { + for _, e := range s.Emojis { + gtsEmoji := >smodel.Emoji{} + if err := c.db.GetByID(e, gtsEmoji); err != nil { + return nil, fmt.Errorf("error getting emoji with id %s: %s", e, err) + } + mastoEmoji, err := c.EmojiToMasto(gtsEmoji) + if err != nil { + return nil, fmt.Errorf("error converting emoji with id %s: %s", gtsEmoji.ID, err) + } + mastoEmojis = append(mastoEmojis, mastoEmoji) + } + } + + var mastoCard *model.Card + var mastoPoll *model.Poll + + return &model.Status{ + ID: s.ID, + CreatedAt: s.CreatedAt.Format(time.RFC3339), + InReplyToID: s.InReplyToID, + InReplyToAccountID: s.InReplyToAccountID, + Sensitive: s.Sensitive, + SpoilerText: s.ContentWarning, + Visibility: c.VisToMasto(s.Visibility), + Language: s.Language, + URI: s.URI, + URL: s.URL, + RepliesCount: repliesCount, + ReblogsCount: reblogsCount, + FavouritesCount: favesCount, + Favourited: faved, + Reblogged: reblogged, + Muted: muted, + Bookmarked: bookmarked, + Pinned: pinned, + Content: s.Content, + Reblog: mastoRebloggedStatus, + Application: mastoApplication, + Account: mastoTargetAccount, + MediaAttachments: mastoAttachments, + Mentions: mastoMentions, + Tags: mastoTags, + Emojis: mastoEmojis, + Card: mastoCard, // TODO: implement cards + Poll: mastoPoll, // TODO: implement polls + Text: s.Text, + }, nil +} + +// VisToMasto converts a gts visibility into its mastodon equivalent +func (c *converter) VisToMasto(m gtsmodel.Visibility) model.Visibility { + switch m { + case gtsmodel.VisibilityPublic: + return model.VisibilityPublic + case gtsmodel.VisibilityUnlocked: + return model.VisibilityUnlisted + case gtsmodel.VisibilityFollowersOnly, gtsmodel.VisibilityMutualsOnly: + return model.VisibilityPrivate + case gtsmodel.VisibilityDirect: + return model.VisibilityDirect + } + return "" +} |