// GoToSocial // Copyright (C) GoToSocial Authors admin@gotosocial.org // SPDX-License-Identifier: AGPL-3.0-or-later // // 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 . package typeutils import ( "context" "crypto/x509" "encoding/pem" "errors" "fmt" "net/url" "strings" "github.com/superseriousbusiness/activity/pub" "github.com/superseriousbusiness/activity/streams" "github.com/superseriousbusiness/activity/streams/vocab" "github.com/superseriousbusiness/gotosocial/internal/ap" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/uris" "github.com/superseriousbusiness/gotosocial/internal/util/xslices" ) // AccountToAS converts a gts model account into an activity streams person, suitable for federation func (c *Converter) AccountToAS(ctx context.Context, 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) // shared inbox -- only add this if we know for sure it has one if a.SharedInboxURI != nil && *a.SharedInboxURI != "" { sharedInboxURI, err := url.Parse(*a.SharedInboxURI) if err != nil { return nil, err } endpointsProp := streams.NewActivityStreamsEndpointsProperty() endpoints := streams.NewActivityStreamsEndpoints() sharedInboxProp := streams.NewActivityStreamsSharedInboxProperty() sharedInboxProp.SetIRI(sharedInboxURI) endpoints.SetActivityStreamsSharedInbox(sharedInboxProp) endpointsProp.AppendActivityStreamsEndpoints(endpoints) person.SetActivityStreamsEndpoints(endpointsProp) } // 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. manuallyApprovesFollowersProp := streams.NewActivityStreamsManuallyApprovesFollowersProperty() manuallyApprovesFollowersProp.Set(*a.Locked) person.SetActivityStreamsManuallyApprovesFollowers(manuallyApprovesFollowersProp) // 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. if l := len(a.AlsoKnownAsURIs); l != 0 { alsoKnownAsURIs := make([]*url.URL, l) for i, rawURL := range a.AlsoKnownAsURIs { uri, err := url.Parse(rawURL) if err != nil { return nil, err } alsoKnownAsURIs[i] = uri } ap.SetAlsoKnownAs(person, alsoKnownAsURIs) } // movedTo // Required for Move activity. if a.MovedToURI != "" { movedTo, err := url.Parse(a.MovedToURI) if err != nil { return nil, err } ap.SetMovedTo(person, movedTo) } // 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) // tags tagProp := streams.NewActivityStreamsTagProperty() // tag -- emojis emojis := a.Emojis if len(a.EmojiIDs) > len(emojis) { emojis = []*gtsmodel.Emoji{} for _, emojiID := range a.EmojiIDs { emoji, err := c.state.DB.GetEmojiByID(ctx, emojiID) if err != nil { return nil, fmt.Errorf("AccountToAS: error getting emoji %s from database: %s", emojiID, err) } emojis = append(emojis, emoji) } } for _, emoji := range emojis { asEmoji, err := c.EmojiToAS(ctx, emoji) if err != nil { return nil, fmt.Errorf("AccountToAS: error converting emoji to AS emoji: %s", err) } tagProp.AppendTootEmoji(asEmoji) } // tag -- hashtags // TODO person.SetActivityStreamsTag(tagProp) // attachment // Used for profile fields. if len(a.Fields) != 0 { attachmentProp := streams.NewActivityStreamsAttachmentProperty() for _, field := range a.Fields { propertyValue := streams.NewSchemaPropertyValue() nameProp := streams.NewActivityStreamsNameProperty() nameProp.AppendXMLSchemaString(field.Name) propertyValue.SetActivityStreamsName(nameProp) valueProp := streams.NewSchemaValueProperty() valueProp.Set(field.Value) propertyValue.SetSchemaValue(valueProp) attachmentProp.AppendSchemaPropertyValue(propertyValue) } person.SetActivityStreamsAttachment(attachmentProp) } // endpoints // NOT IMPLEMENTED -- this is for shared inbox which we don't use // icon // Used as profile avatar. if a.AvatarMediaAttachmentID != "" { if a.AvatarMediaAttachment == nil { avatar, err := c.state.DB.GetAttachmentByID(ctx, a.AvatarMediaAttachmentID) if err == nil { a.AvatarMediaAttachment = avatar } else { log.Errorf(ctx, "error getting Avatar with id %s: %s", a.AvatarMediaAttachmentID, err) } } if a.AvatarMediaAttachment != nil { iconProperty := streams.NewActivityStreamsIconProperty() iconImage := streams.NewActivityStreamsImage() mediaType := streams.NewActivityStreamsMediaTypeProperty() mediaType.Set(a.AvatarMediaAttachment.File.ContentType) iconImage.SetActivityStreamsMediaType(mediaType) avatarURLProperty := streams.NewActivityStreamsUrlProperty() avatarURL, err := url.Parse(a.AvatarMediaAttachment.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 != "" { if a.HeaderMediaAttachment == nil { header, err := c.state.DB.GetAttachmentByID(ctx, a.HeaderMediaAttachmentID) if err == nil { a.HeaderMediaAttachment = header } else { log.Errorf(ctx, "error getting Header with id %s: %s", a.HeaderMediaAttachmentID, err) } } if a.HeaderMediaAttachment != nil { headerProperty := streams.NewActivityStreamsImageProperty() headerImage := streams.NewActivityStreamsImage() mediaType := streams.NewActivityStreamsMediaTypeProperty() mediaType.Set(a.HeaderMediaAttachment.File.ContentType) headerImage.SetActivityStreamsMediaType(mediaType) headerURLProperty := streams.NewActivityStreamsUrlProperty() headerURL, err := url.Parse(a.HeaderMediaAttachment.URL) if err != nil { return nil, err } headerURLProperty.AppendIRI(headerURL) headerImage.SetActivityStreamsUrl(headerURLProperty) headerProperty.AppendActivityStreamsImage(headerImage) person.SetActivityStreamsImage(headerProperty) } } return person, nil } // AccountToASMinimal converts a gts model account into an activity streams person, suitable for federation. // // The returned account will just have the Type, Username, PublicKey, and ID properties set. This is // suitable for serving to requesters to whom we want to give as little information as possible because // we don't trust them (yet). func (c *Converter) AccountToASMinimal(ctx context.Context, 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) // 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) // 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) return person, nil } // StatusToAS converts a gts model status into an ActivityStreams Statusable implementation, suitable for federation func (c *Converter) StatusToAS(ctx context.Context, s *gtsmodel.Status) (ap.Statusable, error) { // Ensure the status model is fully populated. // The status and poll models are REQUIRED so nothing to do if this fails. if err := c.state.DB.PopulateStatus(ctx, s); err != nil { return nil, gtserror.Newf("error populating status: %w", err) } var status ap.Statusable if s.Poll != nil { // If status has poll available, we convert // it as an AS Question (similar to a Note). poll := streams.NewActivityStreamsQuestion() // Add required status poll data to AS Question. if err := c.addPollToAS(s.Poll, poll); err != nil { return nil, gtserror.Newf("error converting poll: %w", err) } // Set poll as status. status = poll } else { // Else we converter it as an AS Note. status = streams.NewActivityStreamsNote() } // id statusURI, err := url.Parse(s.URI) if err != nil { return nil, gtserror.Newf("error parsing url %s: %w", s.URI, err) } statusIDProp := streams.NewJSONLDIdProperty() statusIDProp.SetIRI(statusURI) status.SetJSONLDId(statusIDProp) // type // will be set automatically by go-fed // summary aka cw statusSummaryProp := streams.NewActivityStreamsSummaryProperty() statusSummaryProp.AppendXMLSchemaString(s.ContentWarning) status.SetActivityStreamsSummary(statusSummaryProp) // inReplyTo if s.InReplyToURI != "" { rURI, err := url.Parse(s.InReplyToURI) if err != nil { return nil, gtserror.Newf("error parsing url %s: %w", s.InReplyToURI, err) } inReplyToProp := streams.NewActivityStreamsInReplyToProperty() inReplyToProp.AppendIRI(rURI) status.SetActivityStreamsInReplyTo(inReplyToProp) } // Set created / updated at properties. ap.SetPublished(status, s.CreatedAt) ap.SetUpdated(status, s.UpdatedAt) // url if s.URL != "" { sURL, err := url.Parse(s.URL) if err != nil { return nil, gtserror.Newf("error parsing url %s: %w", s.URL, err) } urlProp := streams.NewActivityStreamsUrlProperty() urlProp.AppendIRI(sURL) status.SetActivityStreamsUrl(urlProp) } // attributedTo authorAccountURI, err := url.Parse(s.Account.URI) if err != nil { return nil, gtserror.Newf("error parsing url %s: %w", s.Account.URI, err) } attributedToProp := streams.NewActivityStreamsAttributedToProperty() attributedToProp.AppendIRI(authorAccountURI) status.SetActivityStreamsAttributedTo(attributedToProp) // tags tagProp := streams.NewActivityStreamsTagProperty() // tag -- mentions mentions := s.Mentions if len(s.MentionIDs) != len(mentions) { mentions, err = c.state.DB.GetMentions(ctx, s.MentionIDs) if err != nil { return nil, gtserror.Newf("error getting mentions: %w", err) } } for _, m := range mentions { asMention, err := c.MentionToAS(ctx, m) if err != nil { return nil, gtserror.Newf("error converting mention to AS mention: %w", err) } tagProp.AppendActivityStreamsMention(asMention) } // tag -- emojis emojis := s.Emojis if len(s.EmojiIDs) != len(emojis) { emojis, err = c.state.DB.GetEmojisByIDs(ctx, s.EmojiIDs) if err != nil { return nil, gtserror.Newf("error getting emojis from database: %w", err) } } for _, emoji := range emojis { asEmoji, err := c.EmojiToAS(ctx, emoji) if err != nil { return nil, gtserror.Newf("error converting emoji to AS emoji: %w", err) } tagProp.AppendTootEmoji(asEmoji) } // tag -- hashtags hashtags := s.Tags if len(s.TagIDs) != len(hashtags) { hashtags, err = c.state.DB.GetTags(ctx, s.TagIDs) if err != nil { return nil, gtserror.Newf("error getting tags: %w", err) } } for _, ht := range hashtags { asHashtag, err := c.TagToAS(ctx, ht) if err != nil { return nil, gtserror.Newf("error converting tag to AS tag: %w", err) } tagProp.AppendTootHashtag(asHashtag) } status.SetActivityStreamsTag(tagProp) // parse out some URIs we need here authorFollowersURI, err := url.Parse(s.Account.FollowersURI) if err != nil { return nil, gtserror.Newf("error parsing url %s: %w", s.Account.FollowersURI, err) } publicURI, err := url.Parse(pub.PublicActivityPubIRI) if err != nil { return nil, gtserror.Newf("error parsing url %s: %w", pub.PublicActivityPubIRI, err) } // to and cc toProp := streams.NewActivityStreamsToProperty() ccProp := streams.NewActivityStreamsCcProperty() switch s.Visibility { case gtsmodel.VisibilityDirect: // if DIRECT, then only mentioned users should be added to TO, and nothing to CC for _, m := range mentions { iri, err := url.Parse(m.TargetAccount.URI) if err != nil { return nil, gtserror.Newf("error parsing uri %s: %w", m.TargetAccount.URI, err) } toProp.AppendIRI(iri) } case gtsmodel.VisibilityMutualsOnly: // TODO case gtsmodel.VisibilityFollowersOnly: // if FOLLOWERS ONLY then we want to add followers to TO, and mentions to CC toProp.AppendIRI(authorFollowersURI) for _, m := range mentions { iri, err := url.Parse(m.TargetAccount.URI) if err != nil { return nil, gtserror.Newf("error parsing uri %s: %w", m.TargetAccount.URI, err) } ccProp.AppendIRI(iri) } case gtsmodel.VisibilityUnlocked: // if UNLOCKED, we want to add followers to TO, and public and mentions to CC toProp.AppendIRI(authorFollowersURI) ccProp.AppendIRI(publicURI) for _, m := range mentions { iri, err := url.Parse(m.TargetAccount.URI) if err != nil { return nil, gtserror.Newf("error parsing uri %s: %w", m.TargetAccount.URI, err) } ccProp.AppendIRI(iri) } case gtsmodel.VisibilityPublic: // if PUBLIC, we want to add public to TO, and followers and mentions to CC toProp.AppendIRI(publicURI) ccProp.AppendIRI(authorFollowersURI) for _, m := range mentions { iri, err := url.Parse(m.TargetAccount.URI) if err != nil { return nil, gtserror.Newf("error parsing uri %s: %w", m.TargetAccount.URI, err) } ccProp.AppendIRI(iri) } } status.SetActivityStreamsTo(toProp) status.SetActivityStreamsCc(ccProp) // conversation // TODO // content -- the actual post // itself, plus the language contentProp := streams.NewActivityStreamsContentProperty() contentProp.AppendXMLSchemaString(s.Content) if s.Language != "" { contentProp.AppendRDFLangString(map[string]string{ s.Language: s.Content, }) } status.SetActivityStreamsContent(contentProp) // attachments attachmentProp := streams.NewActivityStreamsAttachmentProperty() attachments := s.Attachments if len(s.AttachmentIDs) != len(attachments) { attachments, err = c.state.DB.GetAttachmentsByIDs(ctx, s.AttachmentIDs) if err != nil { return nil, gtserror.Newf("error getting attachments from database: %w", err) } } for _, a := range attachments { doc, err := c.AttachmentToAS(ctx, a) if err != nil { return nil, gtserror.Newf("error converting attachment: %w", err) } attachmentProp.AppendActivityStreamsDocument(doc) } status.SetActivityStreamsAttachment(attachmentProp) // replies repliesCollection, err := c.StatusToASRepliesCollection(ctx, s, false) if err != nil { return nil, fmt.Errorf("error creating repliesCollection: %w", err) } repliesProp := streams.NewActivityStreamsRepliesProperty() repliesProp.SetActivityStreamsCollection(repliesCollection) status.SetActivityStreamsReplies(repliesProp) // sensitive sensitiveProp := streams.NewActivityStreamsSensitiveProperty() sensitiveProp.AppendXMLSchemaBoolean(*s.Sensitive) status.SetActivityStreamsSensitive(sensitiveProp) // interactionPolicy var p *gtsmodel.InteractionPolicy if s.InteractionPolicy != nil { // Use InteractionPolicy // set on the status. p = s.InteractionPolicy } else { // Fall back to default policy // for the status's visibility. p = gtsmodel.DefaultInteractionPolicyFor(s.Visibility) } policy, err := c.InteractionPolicyToASInteractionPolicy(ctx, p, s) if err != nil { return nil, fmt.Errorf("error creating interactionPolicy: %w", err) } policyProp := streams.NewGoToSocialInteractionPolicyProperty() policyProp.AppendGoToSocialInteractionPolicy(policy) status.SetGoToSocialInteractionPolicy(policyProp) // Parse + set approvedBy. if s.ApprovedByURI != "" { approvedBy, err := url.Parse(s.ApprovedByURI) if err != nil { return nil, fmt.Errorf("error parsing approvedBy: %w", err) } approvedByProp := streams.NewGoToSocialApprovedByProperty() approvedByProp.Set(approvedBy) status.SetGoToSocialApprovedBy(approvedByProp) } return status, nil } func (c *Converter) addPollToAS(poll *gtsmodel.Poll, dst ap.Pollable) error { var optionsProp interface { // the minimum interface for appending AS Notes // to an AS type options property of some kind. AppendActivityStreamsNote(vocab.ActivityStreamsNote) } if len(poll.Options) != len(poll.Votes) { return gtserror.Newf("invalid poll %s", poll.ID) } if !*poll.HideCounts { // Set total no. voting accounts. ap.SetVotersCount(dst, *poll.Voters) } if *poll.Multiple { // Create new multiple-choice (AnyOf) property for poll. anyOfProp := streams.NewActivityStreamsAnyOfProperty() dst.SetActivityStreamsAnyOf(anyOfProp) optionsProp = anyOfProp } else { // Create new single-choice (OneOf) property for poll. oneOfProp := streams.NewActivityStreamsOneOfProperty() dst.SetActivityStreamsOneOf(oneOfProp) optionsProp = oneOfProp } for i, name := range poll.Options { // Create new Note object to represent option. note := streams.NewActivityStreamsNote() // Create new name property and set the option name. nameProp := streams.NewActivityStreamsNameProperty() nameProp.AppendXMLSchemaString(name) note.SetActivityStreamsName(nameProp) if !*poll.HideCounts { // Create new total items property to hold the vote count. totalItemsProp := streams.NewActivityStreamsTotalItemsProperty() totalItemsProp.Set(poll.Votes[i]) // Create new replies property with collection to encompass count. repliesProp := streams.NewActivityStreamsRepliesProperty() collection := streams.NewActivityStreamsCollection() collection.SetActivityStreamsTotalItems(totalItemsProp) repliesProp.SetActivityStreamsCollection(collection) // Attach the replies to Note object. note.SetActivityStreamsReplies(repliesProp) } // Append the note to options property. optionsProp.AppendActivityStreamsNote(note) } if !poll.ExpiresAt.IsZero() { // Set poll endTime property. ap.SetEndTime(dst, poll.ExpiresAt) } if !poll.ClosedAt.IsZero() { // Poll is closed, set closed property. ap.AppendClosed(dst, poll.ClosedAt) } return nil } // StatusToASDelete converts a gts model status into a Delete of that status, using just the // URI of the status as object, and addressing the Delete appropriately. func (c *Converter) StatusToASDelete(ctx context.Context, s *gtsmodel.Status) (vocab.ActivityStreamsDelete, error) { // Parse / fetch some information // we need to create the Delete. if s.Account == nil { var err error s.Account, err = c.state.DB.GetAccountByID(ctx, s.AccountID) if err != nil { return nil, fmt.Errorf("StatusToASDelete: error retrieving author account from db: %w", err) } } actorIRI, err := url.Parse(s.AccountURI) if err != nil { return nil, fmt.Errorf("StatusToASDelete: error parsing actorIRI %s: %w", s.AccountURI, err) } statusIRI, err := url.Parse(s.URI) if err != nil { return nil, fmt.Errorf("StatusToASDelete: error parsing statusIRI %s: %w", s.URI, err) } // Create a Delete. delete := streams.NewActivityStreamsDelete() // Set appropriate actor for the Delete. deleteActor := streams.NewActivityStreamsActorProperty() deleteActor.AppendIRI(actorIRI) delete.SetActivityStreamsActor(deleteActor) // Set the status IRI as the 'object' property. // We should avoid serializing the whole status // when doing a delete because it's wasteful and // could accidentally leak the now-deleted status. deleteObject := streams.NewActivityStreamsObjectProperty() deleteObject.AppendIRI(statusIRI) delete.SetActivityStreamsObject(deleteObject) // Address the Delete appropriately. toProp := streams.NewActivityStreamsToProperty() ccProp := streams.NewActivityStreamsCcProperty() // Unless the status was a direct message, we can // address the Delete To the ActivityPub Public URI. // This ensures that the Delete will have as wide an // audience as possible. // // Because we're using just the status URI, not the // whole status, it won't leak any sensitive info. // At worst, a remote instance becomes aware of the // URI for a status which is now deleted anyway. if s.Visibility != gtsmodel.VisibilityDirect { publicURI, err := url.Parse(pub.PublicActivityPubIRI) if err != nil { return nil, fmt.Errorf("StatusToASDelete: error parsing url %s: %w", pub.PublicActivityPubIRI, err) } toProp.AppendIRI(publicURI) actorFollowersURI, err := url.Parse(s.Account.FollowersURI) if err != nil { return nil, fmt.Errorf("StatusToASDelete: error parsing url %s: %w", s.Account.FollowersURI, err) } ccProp.AppendIRI(actorFollowersURI) } // Always include the replied-to account and any // mentioned accounts as addressees as well. // // Worst case scenario here is that a replied account // who wasn't mentioned (and perhaps didn't see the // message), sees that someone has now deleted a status // in which they were replied to but not mentioned. In // other words, they *might* see that someone subtooted // about them, but they won't know what was said. // Ensure mentions are populated. mentions := s.Mentions if len(s.MentionIDs) > len(mentions) { mentions, err = c.state.DB.GetMentions(ctx, s.MentionIDs) if err != nil { return nil, fmt.Errorf("StatusToASDelete: error getting mentions: %w", err) } } // Remember which accounts were mentioned // here to avoid duplicating them later. mentionedAccountIDs := make(map[string]interface{}, len(mentions)) // For direct messages, add URI // to To, else just add to CC. var f func(*url.URL) if s.Visibility == gtsmodel.VisibilityDirect { f = toProp.AppendIRI } else { f = ccProp.AppendIRI } for _, m := range mentions { mentionedAccountIDs[m.TargetAccountID] = nil // Remember this ID. iri, err := url.Parse(m.TargetAccount.URI) if err != nil { return nil, fmt.Errorf("StatusToAS: error parsing uri %s: %s", m.TargetAccount.URI, err) } f(iri) } if s.InReplyToAccountID != "" { if _, ok := mentionedAccountIDs[s.InReplyToAccountID]; !ok { // Only address to this account if it // wasn't already included as a mention. if s.InReplyToAccount == nil { s.InReplyToAccount, err = c.state.DB.GetAccountByID(ctx, s.InReplyToAccountID) if err != nil && !errors.Is(err, db.ErrNoEntries) { return nil, fmt.Errorf("StatusToASDelete: db error getting account %s: %w", s.InReplyToAccountID, err) } } if s.InReplyToAccount != nil { inReplyToAccountURI, err := url.Parse(s.InReplyToAccount.URI) if err != nil { return nil, fmt.Errorf("StatusToASDelete: error parsing url %s: %w", s.InReplyToAccount.URI, err) } ccProp.AppendIRI(inReplyToAccountURI) } } } delete.SetActivityStreamsTo(toProp) delete.SetActivityStreamsCc(ccProp) return delete, nil } // FollowToASFollow converts a gts model Follow into an activity streams Follow, suitable for federation func (c *Converter) FollowToAS(ctx context.Context, f *gtsmodel.Follow) (vocab.ActivityStreamsFollow, error) { if err := c.state.DB.PopulateFollow(ctx, f); err != nil { return nil, gtserror.Newf("error populating follow: %w", err) } // Parse out the various URIs we need for this // origin account (who's doing the follow). originAccountURI, err := url.Parse(f.Account.URI) if err != nil { return nil, fmt.Errorf("followtoasfollow: error parsing origin account uri: %s", err) } originActor := streams.NewActivityStreamsActorProperty() originActor.AppendIRI(originAccountURI) // target account (who's being followed) targetAccountURI, err := url.Parse(f.TargetAccount.URI) if err != nil { return nil, fmt.Errorf("followtoasfollow: error parsing target account uri: %s", err) } // uri of the follow activity itself followURI, err := url.Parse(f.URI) if err != nil { return nil, fmt.Errorf("followtoasfollow: error parsing follow uri: %s", err) } // start preparing the follow activity follow := streams.NewActivityStreamsFollow() // set the actor follow.SetActivityStreamsActor(originActor) // set the id followIDProp := streams.NewJSONLDIdProperty() followIDProp.SetIRI(followURI) follow.SetJSONLDId(followIDProp) // set the object followObjectProp := streams.NewActivityStreamsObjectProperty() followObjectProp.AppendIRI(targetAccountURI) follow.SetActivityStreamsObject(followObjectProp) // set the To property followToProp := streams.NewActivityStreamsToProperty() followToProp.AppendIRI(targetAccountURI) follow.SetActivityStreamsTo(followToProp) return follow, nil } // MentionToAS converts a gts model mention into an activity streams Mention, suitable for federation func (c *Converter) MentionToAS(ctx context.Context, m *gtsmodel.Mention) (vocab.ActivityStreamsMention, error) { if m.TargetAccount == nil { a, err := c.state.DB.GetAccountByID(ctx, m.TargetAccountID) if err != nil { return nil, fmt.Errorf("MentionToAS: error getting target account from db: %s", err) } m.TargetAccount = a } // create the mention mention := streams.NewActivityStreamsMention() // href -- this should be the URI of the mentioned user hrefProp := streams.NewActivityStreamsHrefProperty() hrefURI, err := url.Parse(m.TargetAccount.URI) if err != nil { return nil, fmt.Errorf("MentionToAS: error parsing uri %s: %s", m.TargetAccount.URI, err) } hrefProp.SetIRI(hrefURI) mention.SetActivityStreamsHref(hrefProp) // name -- this should be the namestring of the mentioned user, something like @whatever@example.org var domain string if m.TargetAccount.Domain == "" { accountDomain := config.GetAccountDomain() if accountDomain == "" { accountDomain = config.GetHost() } domain = accountDomain } else { domain = m.TargetAccount.Domain } username := m.TargetAccount.Username nameString := fmt.Sprintf("@%s@%s", username, domain) nameProp := streams.NewActivityStreamsNameProperty() nameProp.AppendXMLSchemaString(nameString) mention.SetActivityStreamsName(nameProp) return mention, nil } // TagToAS converts a gts model tag into a toot Hashtag, suitable for federation. func (c *Converter) TagToAS(ctx context.Context, t *gtsmodel.Tag) (vocab.TootHashtag, error) { // This is probably already lowercase, // but let's err on the safe side. nameLower := strings.ToLower(t.Name) tagURLString := uris.URIForTag(nameLower) // Create the tag. tag := streams.NewTootHashtag() // `href` should be the URL of the tag. hrefProp := streams.NewActivityStreamsHrefProperty() tagURL, err := url.Parse(tagURLString) if err != nil { return nil, gtserror.Newf("error parsing url %s: %w", tagURLString, err) } hrefProp.SetIRI(tagURL) tag.SetActivityStreamsHref(hrefProp) // `name` should be the name of the tag with the # prefix. nameProp := streams.NewActivityStreamsNameProperty() nameProp.AppendXMLSchemaString("#" + nameLower) tag.SetActivityStreamsName(nameProp) return tag, nil } // EmojiToAS converts a gts emoji into a mastodon ns Emoji, suitable for federation. // we're making something like this: // // { // "id": "https://example.com/emoji/123", // "type": "Emoji", // "name": ":kappa:", // "icon": { // "type": "Image", // "mediaType": "image/png", // "url": "https://example.com/files/kappa.png" // } // } func (c *Converter) EmojiToAS(ctx context.Context, e *gtsmodel.Emoji) (vocab.TootEmoji, error) { // create the emoji emoji := streams.NewTootEmoji() // set the ID property to the blocks's URI idProp := streams.NewJSONLDIdProperty() idIRI, err := url.Parse(e.URI) if err != nil { return nil, fmt.Errorf("EmojiToAS: error parsing uri %s: %s", e.URI, err) } idProp.Set(idIRI) emoji.SetJSONLDId(idProp) nameProp := streams.NewActivityStreamsNameProperty() nameString := fmt.Sprintf(":%s:", e.Shortcode) nameProp.AppendXMLSchemaString(nameString) emoji.SetActivityStreamsName(nameProp) iconProperty := streams.NewActivityStreamsIconProperty() iconImage := streams.NewActivityStreamsImage() mediaType := streams.NewActivityStreamsMediaTypeProperty() mediaType.Set(e.ImageContentType) iconImage.SetActivityStreamsMediaType(mediaType) emojiURLProperty := streams.NewActivityStreamsUrlProperty() emojiURL, err := url.Parse(e.ImageURL) if err != nil { return nil, fmt.Errorf("EmojiToAS: error parsing url %s: %s", e.ImageURL, err) } emojiURLProperty.AppendIRI(emojiURL) iconImage.SetActivityStreamsUrl(emojiURLProperty) iconProperty.AppendActivityStreamsImage(iconImage) emoji.SetActivityStreamsIcon(iconProperty) updatedProp := streams.NewActivityStreamsUpdatedProperty() updatedProp.Set(e.UpdatedAt) emoji.SetActivityStreamsUpdated(updatedProp) return emoji, nil } // AttachmentToAS converts a gts model media attachment into an activity streams Attachment, suitable for federation func (c *Converter) AttachmentToAS(ctx context.Context, a *gtsmodel.MediaAttachment) (vocab.ActivityStreamsDocument, error) { // type -- Document doc := streams.NewActivityStreamsDocument() // mediaType aka mime content type mediaTypeProp := streams.NewActivityStreamsMediaTypeProperty() mediaTypeProp.Set(a.File.ContentType) doc.SetActivityStreamsMediaType(mediaTypeProp) // url -- for the original image not the thumbnail urlProp := streams.NewActivityStreamsUrlProperty() imageURL, err := url.Parse(a.URL) if err != nil { return nil, fmt.Errorf("AttachmentToAS: error parsing uri %s: %s", a.URL, err) } urlProp.AppendIRI(imageURL) doc.SetActivityStreamsUrl(urlProp) // name -- aka image description nameProp := streams.NewActivityStreamsNameProperty() nameProp.AppendXMLSchemaString(a.Description) doc.SetActivityStreamsName(nameProp) // blurhash blurProp := streams.NewTootBlurhashProperty() blurProp.Set(a.Blurhash) doc.SetTootBlurhash(blurProp) // focalpoint // TODO return doc, nil } // FaveToAS converts a gts model status fave into an activityStreams LIKE, suitable for federation. // We want to end up with something like this: // // { // "@context": "https://www.w3.org/ns/activitystreams", // "actor": "https://ondergrond.org/users/dumpsterqueer", // "id": "https://ondergrond.org/users/dumpsterqueer#likes/44584", // "object": "https://testingtesting123.xyz/users/gotosocial_test_account/statuses/771aea80-a33d-4d6d-8dfd-57d4d2bfcbd4", // "type": "Like" // } func (c *Converter) FaveToAS(ctx context.Context, f *gtsmodel.StatusFave) (vocab.ActivityStreamsLike, error) { // check if targetStatus is already pinned to this fave, and fetch it if not if f.Status == nil { s, err := c.state.DB.GetStatusByID(ctx, f.StatusID) if err != nil { return nil, fmt.Errorf("FaveToAS: error fetching target status from database: %s", err) } f.Status = s } // check if the targetAccount is already pinned to this fave, and fetch it if not if f.TargetAccount == nil { a, err := c.state.DB.GetAccountByID(ctx, f.TargetAccountID) if err != nil { return nil, fmt.Errorf("FaveToAS: error fetching target account from database: %s", err) } f.TargetAccount = a } // check if the faving account is already pinned to this fave, and fetch it if not if f.Account == nil { a, err := c.state.DB.GetAccountByID(ctx, f.AccountID) if err != nil { return nil, fmt.Errorf("FaveToAS: error fetching faving account from database: %s", err) } f.Account = a } // create the like like := streams.NewActivityStreamsLike() // set the actor property to the fave-ing account's URI actorProp := streams.NewActivityStreamsActorProperty() actorIRI, err := url.Parse(f.Account.URI) if err != nil { return nil, fmt.Errorf("FaveToAS: error parsing uri %s: %s", f.Account.URI, err) } actorProp.AppendIRI(actorIRI) like.SetActivityStreamsActor(actorProp) // set the ID property to the fave's URI idProp := streams.NewJSONLDIdProperty() idIRI, err := url.Parse(f.URI) if err != nil { return nil, fmt.Errorf("FaveToAS: error parsing uri %s: %s", f.URI, err) } idProp.Set(idIRI) like.SetJSONLDId(idProp) // set the object property to the target status's URI objectProp := streams.NewActivityStreamsObjectProperty() statusIRI, err := url.Parse(f.Status.URI) if err != nil { return nil, fmt.Errorf("FaveToAS: error parsing uri %s: %s", f.Status.URI, err) } objectProp.AppendIRI(statusIRI) like.SetActivityStreamsObject(objectProp) // set the TO property to the target account's IRI toProp := streams.NewActivityStreamsToProperty() toIRI, err := url.Parse(f.TargetAccount.URI) if err != nil { return nil, fmt.Errorf("FaveToAS: error parsing uri %s: %s", f.TargetAccount.URI, err) } toProp.AppendIRI(toIRI) like.SetActivityStreamsTo(toProp) // Parse + set approvedBy. if f.ApprovedByURI != "" { approvedBy, err := url.Parse(f.ApprovedByURI) if err != nil { return nil, fmt.Errorf("error parsing approvedBy: %w", err) } approvedByProp := streams.NewGoToSocialApprovedByProperty() approvedByProp.Set(approvedBy) like.SetGoToSocialApprovedBy(approvedByProp) } return like, nil } // BoostToAS converts a gts model boost into an activityStreams ANNOUNCE, suitable for federation func (c *Converter) BoostToAS(ctx context.Context, boostWrapperStatus *gtsmodel.Status, boostingAccount *gtsmodel.Account, boostedAccount *gtsmodel.Account) (vocab.ActivityStreamsAnnounce, error) { // the boosted status is probably pinned to the boostWrapperStatus but double check to make sure if boostWrapperStatus.BoostOf == nil { b, err := c.state.DB.GetStatusByID(ctx, boostWrapperStatus.BoostOfID) if err != nil { return nil, fmt.Errorf("BoostToAS: error getting status with ID %s from the db: %s", boostWrapperStatus.BoostOfID, err) } boostWrapperStatus.BoostOf = b } // create the announce announce := streams.NewActivityStreamsAnnounce() // set the actor boosterURI, err := url.Parse(boostingAccount.URI) if err != nil { return nil, fmt.Errorf("BoostToAS: error parsing uri %s: %s", boostingAccount.URI, err) } actorProp := streams.NewActivityStreamsActorProperty() actorProp.AppendIRI(boosterURI) announce.SetActivityStreamsActor(actorProp) // set the ID boostIDURI, err := url.Parse(boostWrapperStatus.URI) if err != nil { return nil, fmt.Errorf("BoostToAS: error parsing uri %s: %s", boostWrapperStatus.URI, err) } idProp := streams.NewJSONLDIdProperty() idProp.SetIRI(boostIDURI) announce.SetJSONLDId(idProp) // set the object boostedStatusURI, err := url.Parse(boostWrapperStatus.BoostOf.URI) if err != nil { return nil, fmt.Errorf("BoostToAS: error parsing uri %s: %s", boostWrapperStatus.BoostOf.URI, err) } objectProp := streams.NewActivityStreamsObjectProperty() objectProp.AppendIRI(boostedStatusURI) announce.SetActivityStreamsObject(objectProp) // set the published time publishedProp := streams.NewActivityStreamsPublishedProperty() publishedProp.Set(boostWrapperStatus.CreatedAt) announce.SetActivityStreamsPublished(publishedProp) // set the to followersURI, err := url.Parse(boostingAccount.FollowersURI) if err != nil { return nil, fmt.Errorf("BoostToAS: error parsing uri %s: %s", boostingAccount.FollowersURI, err) } toProp := streams.NewActivityStreamsToProperty() toProp.AppendIRI(followersURI) announce.SetActivityStreamsTo(toProp) // set the cc ccProp := streams.NewActivityStreamsCcProperty() boostedAccountURI, err := url.Parse(boostedAccount.URI) if err != nil { return nil, fmt.Errorf("BoostToAS: error parsing uri %s: %s", boostedAccount.URI, err) } ccProp.AppendIRI(boostedAccountURI) // maybe CC it to public depending on the boosted status visibility switch boostWrapperStatus.BoostOf.Visibility { case gtsmodel.VisibilityPublic, gtsmodel.VisibilityUnlocked: publicURI, err := url.Parse(pub.PublicActivityPubIRI) if err != nil { return nil, fmt.Errorf("BoostToAS: error parsing uri %s: %s", pub.PublicActivityPubIRI, err) } ccProp.AppendIRI(publicURI) } announce.SetActivityStreamsCc(ccProp) // Parse + set approvedBy. if boostWrapperStatus.ApprovedByURI != "" { approvedBy, err := url.Parse(boostWrapperStatus.ApprovedByURI) if err != nil { return nil, fmt.Errorf("error parsing approvedBy: %w", err) } approvedByProp := streams.NewGoToSocialApprovedByProperty() approvedByProp.Set(approvedBy) announce.SetGoToSocialApprovedBy(approvedByProp) } return announce, nil } // BlockToAS converts a gts model block into an activityStreams BLOCK, suitable for federation. // we want to end up with something like this: // // { // "@context": "https://www.w3.org/ns/activitystreams", // "actor": "https://example.org/users/some_user", // "id":"https://example.org/users/some_user/blocks/SOME_ULID_OF_A_BLOCK", // "object":"https://some_other.instance/users/some_other_user", // "type":"Block" // } func (c *Converter) BlockToAS(ctx context.Context, b *gtsmodel.Block) (vocab.ActivityStreamsBlock, error) { if b.Account == nil { a, err := c.state.DB.GetAccountByID(ctx, b.AccountID) if err != nil { return nil, fmt.Errorf("BlockToAS: error getting block owner account from database: %s", err) } b.Account = a } if b.TargetAccount == nil { a, err := c.state.DB.GetAccountByID(ctx, b.TargetAccountID) if err != nil { return nil, fmt.Errorf("BlockToAS: error getting block target account from database: %s", err) } b.TargetAccount = a } // create the block block := streams.NewActivityStreamsBlock() // set the actor property to the block-ing account's URI actorProp := streams.NewActivityStreamsActorProperty() actorIRI, err := url.Parse(b.Account.URI) if err != nil { return nil, fmt.Errorf("BlockToAS: error parsing uri %s: %s", b.Account.URI, err) } actorProp.AppendIRI(actorIRI) block.SetActivityStreamsActor(actorProp) // set the ID property to the blocks's URI idProp := streams.NewJSONLDIdProperty() idIRI, err := url.Parse(b.URI) if err != nil { return nil, fmt.Errorf("BlockToAS: error parsing uri %s: %s", b.URI, err) } idProp.Set(idIRI) block.SetJSONLDId(idProp) // set the object property to the target account's URI objectProp := streams.NewActivityStreamsObjectProperty() targetIRI, err := url.Parse(b.TargetAccount.URI) if err != nil { return nil, fmt.Errorf("BlockToAS: error parsing uri %s: %s", b.TargetAccount.URI, err) } objectProp.AppendIRI(targetIRI) block.SetActivityStreamsObject(objectProp) // set the TO property to the target account's IRI toProp := streams.NewActivityStreamsToProperty() toIRI, err := url.Parse(b.TargetAccount.URI) if err != nil { return nil, fmt.Errorf("BlockToAS: error parsing uri %s: %s", b.TargetAccount.URI, err) } toProp.AppendIRI(toIRI) block.SetActivityStreamsTo(toProp) return block, nil } // StatusToASRepliesCollection converts a gts model status into an activityStreams REPLIES collection. // the goal is to end up with something like this: // // { // "@context": "https://www.w3.org/ns/activitystreams", // "id": "https://example.org/users/whatever/statuses/01FCNEXAGAKPEX1J7VJRPJP490/replies", // "type": "Collection", // "first": { // "id": "https://example.org/users/whatever/statuses/01FCNEXAGAKPEX1J7VJRPJP490/replies?page=true", // "type": "CollectionPage", // "next": "https://example.org/users/whatever/statuses/01FCNEXAGAKPEX1J7VJRPJP490/replies?only_other_accounts=true&page=true", // "partOf": "https://example.org/users/whatever/statuses/01FCNEXAGAKPEX1J7VJRPJP490/replies", // "items": [] // } // } func (c *Converter) StatusToASRepliesCollection(ctx context.Context, status *gtsmodel.Status, onlyOtherAccounts bool) (vocab.ActivityStreamsCollection, error) { collectionID := fmt.Sprintf("%s/replies", status.URI) collectionIDURI, err := url.Parse(collectionID) if err != nil { return nil, err } collection := streams.NewActivityStreamsCollection() // collection.id collectionIDProp := streams.NewJSONLDIdProperty() collectionIDProp.SetIRI(collectionIDURI) collection.SetJSONLDId(collectionIDProp) // first first := streams.NewActivityStreamsFirstProperty() firstPage := streams.NewActivityStreamsCollectionPage() // first.id firstPageIDProp := streams.NewJSONLDIdProperty() firstPageID, err := url.Parse(fmt.Sprintf("%s?page=true", collectionID)) if err != nil { return nil, gtserror.NewErrorInternalError(err) } firstPageIDProp.SetIRI(firstPageID) firstPage.SetJSONLDId(firstPageIDProp) // first.next nextProp := streams.NewActivityStreamsNextProperty() nextPropID, err := url.Parse(fmt.Sprintf("%s?only_other_accounts=%t&page=true", collectionID, onlyOtherAccounts)) if err != nil { return nil, gtserror.NewErrorInternalError(err) } nextProp.SetIRI(nextPropID) firstPage.SetActivityStreamsNext(nextProp) // first.partOf partOfProp := streams.NewActivityStreamsPartOfProperty() partOfProp.SetIRI(collectionIDURI) firstPage.SetActivityStreamsPartOf(partOfProp) first.SetActivityStreamsCollectionPage(firstPage) // collection.first collection.SetActivityStreamsFirst(first) return collection, nil } // StatusURIsToASRepliesPage returns a collection page with appropriate next/part of pagination. // the goal is to end up with something like this: // // { // "@context": "https://www.w3.org/ns/activitystreams", // "id": "https://example.org/users/whatever/statuses/01FCNEXAGAKPEX1J7VJRPJP490/replies?only_other_accounts=true&page=true", // "type": "CollectionPage", // "next": "https://example.org/users/whatever/statuses/01FCNEXAGAKPEX1J7VJRPJP490/replies?min_id=106720870266901180&only_other_accounts=true&page=true", // "partOf": "https://example.org/users/whatever/statuses/01FCNEXAGAKPEX1J7VJRPJP490/replies", // "items": [ // "https://example.com/users/someone/statuses/106720752853216226", // "https://somewhere.online/users/eeeeeeeeeep/statuses/106720870163727231" // ] // } func (c *Converter) StatusURIsToASRepliesPage(ctx context.Context, status *gtsmodel.Status, onlyOtherAccounts bool, minID string, replies map[string]*url.URL) (vocab.ActivityStreamsCollectionPage, error) { collectionID := fmt.Sprintf("%s/replies", status.URI) page := streams.NewActivityStreamsCollectionPage() // .id pageIDProp := streams.NewJSONLDIdProperty() pageIDString := fmt.Sprintf("%s?page=true&only_other_accounts=%t", collectionID, onlyOtherAccounts) if minID != "" { pageIDString = fmt.Sprintf("%s&min_id=%s", pageIDString, minID) } pageID, err := url.Parse(pageIDString) if err != nil { return nil, gtserror.NewErrorInternalError(err) } pageIDProp.SetIRI(pageID) page.SetJSONLDId(pageIDProp) // .partOf collectionIDURI, err := url.Parse(collectionID) if err != nil { return nil, err } partOfProp := streams.NewActivityStreamsPartOfProperty() partOfProp.SetIRI(collectionIDURI) page.SetActivityStreamsPartOf(partOfProp) // .items items := streams.NewActivityStreamsItemsProperty() var highestID string for k, v := range replies { items.AppendIRI(v) if k > highestID { highestID = k } } page.SetActivityStreamsItems(items) // .next nextProp := streams.NewActivityStreamsNextProperty() nextPropIDString := fmt.Sprintf("%s?only_other_accounts=%t&page=true", collectionID, onlyOtherAccounts) if highestID != "" { nextPropIDString = fmt.Sprintf("%s&min_id=%s", nextPropIDString, highestID) } nextPropID, err := url.Parse(nextPropIDString) if err != nil { return nil, gtserror.NewErrorInternalError(err) } nextProp.SetIRI(nextPropID) page.SetActivityStreamsNext(nextProp) return page, nil } // StatusesToASOutboxPage returns an ordered collection page using the given statuses and parameters as contents. // // The maxID and minID should be the parameters that were passed to the database to obtain the given statuses. // These will be used to create the 'id' field of the collection. // // OutboxID is used to create the 'partOf' field in the collection. // // Appropriate 'next' and 'prev' fields will be created based on the highest and lowest IDs present in the statuses slice. // the goal is to end up with something like this: // // { // "id": "https://example.org/users/whatever/outbox?page=true", // "type": "OrderedCollectionPage", // "next": "https://example.org/users/whatever/outbox?max_id=01FJC1Q0E3SSQR59TD2M1KP4V8&page=true", // "prev": "https://example.org/users/whatever/outbox?min_id=01FJC1Q0E3SSQR59TD2M1KP4V8&page=true", // "partOf": "https://example.org/users/whatever/outbox", // "orderedItems": [ // "id": "https://example.org/users/whatever/statuses/01FJC1MKPVX2VMWP2ST93Q90K7/activity", // "type": "Create", // "actor": "https://example.org/users/whatever", // "published": "2021-10-18T20:06:18Z", // "to": [ // "https://www.w3.org/ns/activitystreams#Public" // ], // "cc": [ // "https://example.org/users/whatever/followers" // ], // "object": "https://example.org/users/whatever/statuses/01FJC1MKPVX2VMWP2ST93Q90K7" // ] // } func (c *Converter) StatusesToASOutboxPage(ctx context.Context, outboxID string, maxID string, minID string, statuses []*gtsmodel.Status) (vocab.ActivityStreamsOrderedCollectionPage, error) { page := streams.NewActivityStreamsOrderedCollectionPage() // .id pageIDProp := streams.NewJSONLDIdProperty() pageID := fmt.Sprintf("%s?page=true", outboxID) if minID != "" { pageID = fmt.Sprintf("%s&minID=%s", pageID, minID) } if maxID != "" { pageID = fmt.Sprintf("%s&maxID=%s", pageID, maxID) } pageIDURI, err := url.Parse(pageID) if err != nil { return nil, err } pageIDProp.SetIRI(pageIDURI) page.SetJSONLDId(pageIDProp) // .partOf collectionIDURI, err := url.Parse(outboxID) if err != nil { return nil, err } partOfProp := streams.NewActivityStreamsPartOfProperty() partOfProp.SetIRI(collectionIDURI) page.SetActivityStreamsPartOf(partOfProp) // .orderedItems itemsProp := streams.NewActivityStreamsOrderedItemsProperty() var highest string var lowest string for _, s := range statuses { note, err := c.StatusToAS(ctx, s) if err != nil { return nil, err } activity := WrapStatusableInCreate(note, true) itemsProp.AppendActivityStreamsCreate(activity) if highest == "" || s.ID > highest { highest = s.ID } if lowest == "" || s.ID < lowest { lowest = s.ID } } page.SetActivityStreamsOrderedItems(itemsProp) // .next if lowest != "" { nextProp := streams.NewActivityStreamsNextProperty() nextPropIDString := fmt.Sprintf("%s?page=true&max_id=%s", outboxID, lowest) nextPropIDURI, err := url.Parse(nextPropIDString) if err != nil { return nil, err } nextProp.SetIRI(nextPropIDURI) page.SetActivityStreamsNext(nextProp) } // .prev if highest != "" { prevProp := streams.NewActivityStreamsPrevProperty() prevPropIDString := fmt.Sprintf("%s?page=true&min_id=%s", outboxID, highest) prevPropIDURI, err := url.Parse(prevPropIDString) if err != nil { return nil, err } prevProp.SetIRI(prevPropIDURI) page.SetActivityStreamsPrev(prevProp) } return page, nil } // StatusesToASFeaturedCollection converts a slice of statuses into an ordered collection // of URIs, suitable for serializing and serving via the activitypub API. func (c *Converter) StatusesToASFeaturedCollection(ctx context.Context, featuredCollectionID string, statuses []*gtsmodel.Status) (vocab.ActivityStreamsOrderedCollection, error) { collection := streams.NewActivityStreamsOrderedCollection() collectionIDProp := streams.NewJSONLDIdProperty() featuredCollectionIDURI, err := url.Parse(featuredCollectionID) if err != nil { return nil, fmt.Errorf("error parsing url %s", featuredCollectionID) } collectionIDProp.SetIRI(featuredCollectionIDURI) collection.SetJSONLDId(collectionIDProp) itemsProp := streams.NewActivityStreamsOrderedItemsProperty() for _, s := range statuses { uri, err := url.Parse(s.URI) if err != nil { return nil, fmt.Errorf("error parsing url %s", s.URI) } itemsProp.AppendIRI(uri) } collection.SetActivityStreamsOrderedItems(itemsProp) totalItemsProp := streams.NewActivityStreamsTotalItemsProperty() totalItemsProp.Set(len(statuses)) collection.SetActivityStreamsTotalItems(totalItemsProp) return collection, nil } // ReportToASFlag converts a gts model report into an activitystreams FLAG, suitable for federation. func (c *Converter) ReportToASFlag(ctx context.Context, r *gtsmodel.Report) (vocab.ActivityStreamsFlag, error) { flag := streams.NewActivityStreamsFlag() flagIDProp := streams.NewJSONLDIdProperty() idURI, err := url.Parse(r.URI) if err != nil { return nil, fmt.Errorf("error parsing url %s: %w", r.URI, err) } flagIDProp.SetIRI(idURI) flag.SetJSONLDId(flagIDProp) // for privacy, set the actor as the INSTANCE ACTOR, // not as the actor who created the report instanceAccount, err := c.state.DB.GetInstanceAccount(ctx, "") if err != nil { return nil, fmt.Errorf("error getting instance account: %w", err) } instanceAccountIRI, err := url.Parse(instanceAccount.URI) if err != nil { return nil, fmt.Errorf("error parsing url %s: %w", instanceAccount.URI, err) } flagActorProp := streams.NewActivityStreamsActorProperty() flagActorProp.AppendIRI(instanceAccountIRI) flag.SetActivityStreamsActor(flagActorProp) // content should be the comment submitted when the report was created contentProp := streams.NewActivityStreamsContentProperty() contentProp.AppendXMLSchemaString(r.Comment) flag.SetActivityStreamsContent(contentProp) // set at least the target account uri as the object of the flag objectProp := streams.NewActivityStreamsObjectProperty() targetAccountURI, err := url.Parse(r.TargetAccount.URI) if err != nil { return nil, fmt.Errorf("error parsing url %s: %w", r.TargetAccount.URI, err) } objectProp.AppendIRI(targetAccountURI) // also set status URIs if they were provided with the report for _, s := range r.Statuses { statusURI, err := url.Parse(s.URI) if err != nil { return nil, fmt.Errorf("error parsing url %s: %w", s.URI, err) } objectProp.AppendIRI(statusURI) } flag.SetActivityStreamsObject(objectProp) return flag, nil } // PollVoteToASCreate converts a vote on a poll into a Create // activity, suitable for federation, with each choice in the // vote appended as a Note to the Create's Object field. // // TODO: as soon as other AP server implementations support // the use of multiple objects in a single create, update this // to return just the one create event again. func (c *Converter) PollVoteToASCreates( ctx context.Context, vote *gtsmodel.PollVote, ) ([]vocab.ActivityStreamsCreate, error) { if len(vote.Choices) == 0 { panic("no vote.Choices") } // Ensure the vote is fully populated (this fetches author). if err := c.state.DB.PopulatePollVote(ctx, vote); err != nil { return nil, gtserror.Newf("error populating vote from db: %w", err) } // Get the vote author. author := vote.Account // Get the JSONLD ID IRI for vote author. authorIRI, err := url.Parse(author.URI) if err != nil { return nil, gtserror.Newf("invalid author uri: %w", err) } // Get the vote poll. poll := vote.Poll // Ensure the poll is fully populated with status. if err := c.state.DB.PopulatePoll(ctx, poll); err != nil { return nil, gtserror.Newf("error populating poll from db: %w", err) } // Get the JSONLD ID IRI for poll's source status. statusIRI, err := url.Parse(poll.Status.URI) if err != nil { return nil, gtserror.Newf("invalid status uri: %w", err) } // Get the JSONLD ID IRI for poll's author account. pollAuthorIRI, err := url.Parse(poll.Status.AccountURI) if err != nil { return nil, gtserror.Newf("invalid account uri: %w", err) } // Parse each choice to a Note and add it to the list of Creates. creates := make([]vocab.ActivityStreamsCreate, len(vote.Choices)) for i, choice := range vote.Choices { // Allocate Create activity and address 'To' poll author. create := streams.NewActivityStreamsCreate() ap.AppendTo(create, pollAuthorIRI) // Create ID formatted as: {$voterIRI}/activity#vote{$index}/{$statusIRI}. createID := fmt.Sprintf("%s/activity#vote%d/%s", author.URI, i, poll.Status.URI) ap.MustSet(ap.SetJSONLDIdStr, ap.WithJSONLDId(create), createID) // Set Create actor appropriately. ap.AppendActorIRIs(create, authorIRI) // Set publish time for activity. ap.SetPublished(create, vote.CreatedAt) // Allocate new note to hold the vote. note := streams.NewActivityStreamsNote() // For AP IRI generate from author URI + poll ID + vote choice. id := fmt.Sprintf("%s#%s/votes/%d", author.URI, poll.ID, choice) ap.MustSet(ap.SetJSONLDIdStr, ap.WithJSONLDId(note), id) // Attach new name property to note with vote choice. nameProp := streams.NewActivityStreamsNameProperty() nameProp.AppendXMLSchemaString(poll.Options[choice]) note.SetActivityStreamsName(nameProp) // Set 'to', 'attribTo', 'inReplyTo' fields. ap.AppendAttributedTo(note, authorIRI) ap.AppendInReplyTo(note, statusIRI) ap.AppendTo(note, pollAuthorIRI) // Append this note to the Create Object. appendStatusableToActivity(create, note, false) // Set create in slice. creates[i] = create } return creates, nil } // populateValuesForProp appends the given PolicyValues // to the given property, for the given status. func populateValuesForProp[T ap.WithIRI]( prop ap.Property[T], status *gtsmodel.Status, urns gtsmodel.PolicyValues, ) error { iriStrs := make([]string, 0) for _, urn := range urns { switch urn { case gtsmodel.PolicyValueAuthor: iriStrs = append(iriStrs, status.Account.URI) case gtsmodel.PolicyValueMentioned: for _, m := range status.Mentions { iriStrs = append(iriStrs, m.TargetAccount.URI) } case gtsmodel.PolicyValueFollowing: iriStrs = append(iriStrs, status.Account.FollowingURI) case gtsmodel.PolicyValueFollowers: iriStrs = append(iriStrs, status.Account.FollowersURI) case gtsmodel.PolicyValuePublic: iriStrs = append(iriStrs, pub.PublicActivityPubIRI) default: iriStrs = append(iriStrs, string(urn)) } } // Deduplicate the iri strings to // make sure we're not parsing + adding // the same string multiple times. iriStrs = xslices.Deduplicate(iriStrs) // Append them to the property. for _, iriStr := range iriStrs { iri, err := url.Parse(iriStr) if err != nil { return err } prop.AppendIRI(iri) } return nil } // InteractionPolicyToASInteractionPolicy returns a // GoToSocial interaction policy suitable for federation. func (c *Converter) InteractionPolicyToASInteractionPolicy( ctx context.Context, interactionPolicy *gtsmodel.InteractionPolicy, status *gtsmodel.Status, ) (vocab.GoToSocialInteractionPolicy, error) { policy := streams.NewGoToSocialInteractionPolicy() /* CAN LIKE */ // Build canLike canLike := streams.NewGoToSocialCanLike() // Build canLike.always canLikeAlwaysProp := streams.NewGoToSocialAlwaysProperty() if err := populateValuesForProp( canLikeAlwaysProp, status, interactionPolicy.CanLike.Always, ); err != nil { return nil, gtserror.Newf("error setting canLike.always: %w", err) } // Set canLike.always canLike.SetGoToSocialAlways(canLikeAlwaysProp) // Build canLike.approvalRequired canLikeApprovalRequiredProp := streams.NewGoToSocialApprovalRequiredProperty() if err := populateValuesForProp( canLikeApprovalRequiredProp, status, interactionPolicy.CanLike.WithApproval, ); err != nil { return nil, gtserror.Newf("error setting canLike.approvalRequired: %w", err) } // Set canLike.approvalRequired. canLike.SetGoToSocialApprovalRequired(canLikeApprovalRequiredProp) // Set canLike on the policy. canLikeProp := streams.NewGoToSocialCanLikeProperty() canLikeProp.AppendGoToSocialCanLike(canLike) policy.SetGoToSocialCanLike(canLikeProp) /* CAN REPLY */ // Build canReply canReply := streams.NewGoToSocialCanReply() // Build canReply.always canReplyAlwaysProp := streams.NewGoToSocialAlwaysProperty() if err := populateValuesForProp( canReplyAlwaysProp, status, interactionPolicy.CanReply.Always, ); err != nil { return nil, gtserror.Newf("error setting canReply.always: %w", err) } // Set canReply.always canReply.SetGoToSocialAlways(canReplyAlwaysProp) // Build canReply.approvalRequired canReplyApprovalRequiredProp := streams.NewGoToSocialApprovalRequiredProperty() if err := populateValuesForProp( canReplyApprovalRequiredProp, status, interactionPolicy.CanReply.WithApproval, ); err != nil { return nil, gtserror.Newf("error setting canReply.approvalRequired: %w", err) } // Set canReply.approvalRequired. canReply.SetGoToSocialApprovalRequired(canReplyApprovalRequiredProp) // Set canReply on the policy. canReplyProp := streams.NewGoToSocialCanReplyProperty() canReplyProp.AppendGoToSocialCanReply(canReply) policy.SetGoToSocialCanReply(canReplyProp) /* CAN ANNOUNCE */ // Build canAnnounce canAnnounce := streams.NewGoToSocialCanAnnounce() // Build canAnnounce.always canAnnounceAlwaysProp := streams.NewGoToSocialAlwaysProperty() if err := populateValuesForProp( canAnnounceAlwaysProp, status, interactionPolicy.CanAnnounce.Always, ); err != nil { return nil, gtserror.Newf("error setting canAnnounce.always: %w", err) } // Set canAnnounce.always canAnnounce.SetGoToSocialAlways(canAnnounceAlwaysProp) // Build canAnnounce.approvalRequired canAnnounceApprovalRequiredProp := streams.NewGoToSocialApprovalRequiredProperty() if err := populateValuesForProp( canAnnounceApprovalRequiredProp, status, interactionPolicy.CanAnnounce.WithApproval, ); err != nil { return nil, gtserror.Newf("error setting canAnnounce.approvalRequired: %w", err) } // Set canAnnounce.approvalRequired. canAnnounce.SetGoToSocialApprovalRequired(canAnnounceApprovalRequiredProp) // Set canAnnounce on the policy. canAnnounceProp := streams.NewGoToSocialCanAnnounceProperty() canAnnounceProp.AppendGoToSocialCanAnnounce(canAnnounce) policy.SetGoToSocialCanAnnounce(canAnnounceProp) return policy, nil } // InteractionReqToASAccept converts a *gtsmodel.InteractionRequest // to an ActivityStreams Accept, addressed to the interacting account. func (c *Converter) InteractionReqToASAccept( ctx context.Context, req *gtsmodel.InteractionRequest, ) (vocab.ActivityStreamsAccept, error) { accept := streams.NewActivityStreamsAccept() acceptID, err := url.Parse(req.URI) if err != nil { return nil, gtserror.Newf("invalid accept uri: %w", err) } actorIRI, err := url.Parse(req.TargetAccount.URI) if err != nil { return nil, gtserror.Newf("invalid account uri: %w", err) } objectIRI, err := url.Parse(req.InteractionURI) if err != nil { return nil, gtserror.Newf("invalid target uri: %w", err) } toIRI, err := url.Parse(req.InteractingAccount.URI) if err != nil { return nil, gtserror.Newf("invalid interacting account uri: %w", err) } // Set id to the URI of // interaction request. ap.SetJSONLDId(accept, acceptID) // Actor is the account that // owns the approval / accept. ap.AppendActorIRIs(accept, actorIRI) // Object is the interaction URI. ap.AppendObjectIRIs(accept, objectIRI) // Address to the owner // of interaction URI. ap.AppendTo(accept, toIRI) // Whether or not we cc this Accept to // followers and public depends on the // type of interaction it Accepts. var cc bool switch req.InteractionType { case gtsmodel.InteractionLike: // Accept of Like doesn't get cc'd // because it's not that important. case gtsmodel.InteractionReply: // Accept of reply gets cc'd. cc = true case gtsmodel.InteractionAnnounce: // Accept of announce gets cc'd. cc = true } if cc { publicIRI, err := url.Parse(pub.PublicActivityPubIRI) if err != nil { return nil, gtserror.Newf("invalid public uri: %w", err) } followersIRI, err := url.Parse(req.TargetAccount.FollowersURI) if err != nil { return nil, gtserror.Newf("invalid followers uri: %w", err) } ap.AppendCc(accept, publicIRI, followersIRI) } return accept, nil } // InteractionReqToASReject converts a *gtsmodel.InteractionRequest // to an ActivityStreams Reject, addressed to the interacting account. func (c *Converter) InteractionReqToASReject( ctx context.Context, req *gtsmodel.InteractionRequest, ) (vocab.ActivityStreamsReject, error) { reject := streams.NewActivityStreamsReject() rejectID, err := url.Parse(req.URI) if err != nil { return nil, gtserror.Newf("invalid reject uri: %w", err) } actorIRI, err := url.Parse(req.TargetAccount.URI) if err != nil { return nil, gtserror.Newf("invalid account uri: %w", err) } objectIRI, err := url.Parse(req.InteractionURI) if err != nil { return nil, gtserror.Newf("invalid target uri: %w", err) } toIRI, err := url.Parse(req.InteractingAccount.URI) if err != nil { return nil, gtserror.Newf("invalid interacting account uri: %w", err) } // Set id to the URI of // interaction request. ap.SetJSONLDId(reject, rejectID) // Actor is the account that // owns the approval / reject. ap.AppendActorIRIs(reject, actorIRI) // Object is the interaction URI. ap.AppendObjectIRIs(reject, objectIRI) // Address to the owner // of interaction URI. ap.AppendTo(reject, toIRI) // Whether or not we cc this Reject to // followers and public depends on the // type of interaction it Rejects. var cc bool switch req.InteractionType { case gtsmodel.InteractionLike: // Reject of Like doesn't get cc'd // because it's not that important. case gtsmodel.InteractionReply: // Reject of reply gets cc'd. cc = true case gtsmodel.InteractionAnnounce: // Reject of announce gets cc'd. cc = true } if cc { publicIRI, err := url.Parse(pub.PublicActivityPubIRI) if err != nil { return nil, gtserror.Newf("invalid public uri: %w", err) } followersIRI, err := url.Parse(req.TargetAccount.FollowersURI) if err != nil { return nil, gtserror.Newf("invalid followers uri: %w", err) } ap.AppendCc(reject, publicIRI, followersIRI) } return reject, nil }