summaryrefslogtreecommitdiff
path: root/internal/typeutils
diff options
context:
space:
mode:
authorLibravatar Tobi Smethurst <31960611+tsmethurst@users.noreply.github.com>2021-05-08 14:25:55 +0200
committerLibravatar GitHub <noreply@github.com>2021-05-08 14:25:55 +0200
commit6f5c045284d34ba580d3007f70b97e05d6760527 (patch)
tree7614da22fba906361a918fb3527465b39272ac93 /internal/typeutils
parentRevert "make boosts work woo (#12)" (#15) (diff)
downloadgotosocial-6f5c045284d34ba580d3007f70b97e05d6760527.tar.xz
Ap (#14)
Big restructuring and initial work on activitypub
Diffstat (limited to 'internal/typeutils')
-rw-r--r--internal/typeutils/accountable.go101
-rw-r--r--internal/typeutils/asextractionutil.go216
-rw-r--r--internal/typeutils/astointernal.go164
-rw-r--r--internal/typeutils/astointernal_test.go206
-rw-r--r--internal/typeutils/converter.go113
-rw-r--r--internal/typeutils/converter_test.go40
-rw-r--r--internal/typeutils/frontendtointernal.go39
-rw-r--r--internal/typeutils/internaltoas.go260
-rw-r--r--internal/typeutils/internaltoas_test.go76
-rw-r--r--internal/typeutils/internaltofrontend.go505
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 := &gtsmodel.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 = &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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 ""
+}