mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2024-11-22 11:46:40 +00:00
ac48192562
* don't drop all vote counts if hideCounts is set, refactors poll option extraction slightly * omit voters_count when not set * make voters_count a ptr to ensure it is omit unless definitely needed * handle case of expires_at, voters_count and option.votes_count being nilable * faster isNil check * remove omitempty tags since mastodon API marks things as nullable but still sets them in outgoing json
1320 lines
34 KiB
Go
1320 lines
34 KiB
Go
// 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 <http://www.gnu.org/licenses/>.
|
|
|
|
package ap
|
|
|
|
import (
|
|
"crypto"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/superseriousbusiness/activity/pub"
|
|
"github.com/superseriousbusiness/activity/streams/vocab"
|
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
|
"github.com/superseriousbusiness/gotosocial/internal/text"
|
|
)
|
|
|
|
// ExtractObjects will extract object vocab.Types from given implementing interface.
|
|
func ExtractObjects(with WithObject) []TypeOrIRI {
|
|
// Extract the attached object (if any).
|
|
objProp := with.GetActivityStreamsObject()
|
|
if objProp == nil {
|
|
return nil
|
|
}
|
|
|
|
// Check for zero len.
|
|
if objProp.Len() == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Accumulate all of the objects into a slice.
|
|
objs := make([]TypeOrIRI, objProp.Len())
|
|
for i := 0; i < objProp.Len(); i++ {
|
|
objs[i] = objProp.At(i)
|
|
}
|
|
|
|
return objs
|
|
}
|
|
|
|
// ExtractActivityData will extract the usable data type (e.g. Note, Question, etc) and corresponding JSON, from activity.
|
|
func ExtractActivityData(activity pub.Activity, rawJSON map[string]any) ([]TypeOrIRI, []any, bool) {
|
|
switch typeName := activity.GetTypeName(); {
|
|
// Activity (has "object").
|
|
case isActivity(typeName):
|
|
objTypes := ExtractObjects(activity)
|
|
if len(objTypes) == 0 {
|
|
return nil, nil, false
|
|
}
|
|
|
|
var objJSON []any
|
|
switch json := rawJSON["object"].(type) {
|
|
case nil:
|
|
// do nothing
|
|
case map[string]any:
|
|
// Wrap map in slice.
|
|
objJSON = []any{json}
|
|
case []any:
|
|
// Use existing slice.
|
|
objJSON = json
|
|
}
|
|
|
|
return objTypes, objJSON, true
|
|
|
|
// IntransitiveAcitivity (no "object").
|
|
case isIntransitiveActivity(typeName):
|
|
asTypeOrIRI := _TypeOrIRI{activity} // wrap activity.
|
|
return []TypeOrIRI{&asTypeOrIRI}, []any{rawJSON}, true
|
|
|
|
// Unknown.
|
|
default:
|
|
return nil, nil, false
|
|
}
|
|
}
|
|
|
|
// ExtractAccountables extracts Accountable objects from a slice TypeOrIRI, returning extracted and remaining TypeOrIRIs.
|
|
func ExtractAccountables(arr []TypeOrIRI) ([]Accountable, []TypeOrIRI) {
|
|
var accounts []Accountable
|
|
|
|
for i := 0; i < len(arr); i++ {
|
|
elem := arr[i]
|
|
|
|
if elem.IsIRI() {
|
|
// skip IRIs
|
|
continue
|
|
}
|
|
|
|
// Extract AS vocab type
|
|
// associated with elem.
|
|
t := elem.GetType()
|
|
|
|
// Try cast AS type as Accountable.
|
|
account, ok := ToAccountable(t)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
// Add casted accountable type.
|
|
accounts = append(accounts, account)
|
|
|
|
// Drop elem from slice.
|
|
copy(arr[:i], arr[i+1:])
|
|
arr = arr[:len(arr)-1]
|
|
}
|
|
|
|
return accounts, arr
|
|
}
|
|
|
|
// ExtractStatusables extracts Statusable objects from a slice TypeOrIRI, returning extracted and remaining TypeOrIRIs.
|
|
func ExtractStatusables(arr []TypeOrIRI) ([]Statusable, []TypeOrIRI) {
|
|
var statuses []Statusable
|
|
|
|
for i := 0; i < len(arr); i++ {
|
|
elem := arr[i]
|
|
|
|
if elem.IsIRI() {
|
|
// skip IRIs
|
|
continue
|
|
}
|
|
|
|
// Extract AS vocab type
|
|
// associated with elem.
|
|
t := elem.GetType()
|
|
|
|
// Try cast AS type as Statusable.
|
|
status, ok := ToStatusable(t)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
// Add casted Statusable type.
|
|
statuses = append(statuses, status)
|
|
|
|
// Drop elem from slice.
|
|
copy(arr[:i], arr[i+1:])
|
|
arr = arr[:len(arr)-1]
|
|
}
|
|
|
|
return statuses, arr
|
|
}
|
|
|
|
// ExtractPollOptionables extracts PollOptionable objects from a slice TypeOrIRI, returning extracted and remaining TypeOrIRIs.
|
|
func ExtractPollOptionables(arr []TypeOrIRI) ([]PollOptionable, []TypeOrIRI) {
|
|
var options []PollOptionable
|
|
|
|
for i := 0; i < len(arr); i++ {
|
|
elem := arr[i]
|
|
|
|
if elem.IsIRI() {
|
|
// skip IRIs
|
|
continue
|
|
}
|
|
|
|
// Extract AS vocab type
|
|
// associated with elem.
|
|
t := elem.GetType()
|
|
|
|
// Try cast as PollOptionable.
|
|
option, ok := ToPollOptionable(t)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
// Add casted PollOptionable type.
|
|
options = append(options, option)
|
|
|
|
// Drop elem from slice.
|
|
copy(arr[:i], arr[i+1:])
|
|
arr = arr[:len(arr)-1]
|
|
}
|
|
|
|
return options, arr
|
|
}
|
|
|
|
// ExtractPreferredUsername returns a string representation of
|
|
// an interface's preferredUsername property. Will return an
|
|
// error if preferredUsername is nil, not a string, or empty.
|
|
func ExtractPreferredUsername(i WithPreferredUsername) (string, error) {
|
|
u := i.GetActivityStreamsPreferredUsername()
|
|
if u == nil || !u.IsXMLSchemaString() {
|
|
return "", gtserror.New("preferredUsername nil or not a string")
|
|
}
|
|
|
|
if u.GetXMLSchemaString() == "" {
|
|
return "", gtserror.New("preferredUsername was empty")
|
|
}
|
|
|
|
return u.GetXMLSchemaString(), nil
|
|
}
|
|
|
|
// ExtractName returns the first string representation it
|
|
// can find of an interface's name property, or an empty
|
|
// string if this is not found.
|
|
func ExtractName(i WithName) string {
|
|
nameProp := i.GetActivityStreamsName()
|
|
if nameProp == nil {
|
|
return ""
|
|
}
|
|
|
|
for iter := nameProp.Begin(); iter != nameProp.End(); iter = iter.Next() {
|
|
// Name may be parsed as IRI, depending on
|
|
// how it's formatted, so account for this.
|
|
switch {
|
|
case iter.IsXMLSchemaString():
|
|
return iter.GetXMLSchemaString()
|
|
case iter.IsIRI():
|
|
return iter.GetIRI().String()
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// ExtractInReplyToURI extracts the first inReplyTo URI
|
|
// property it can find from an interface. Will return
|
|
// nil if no valid URI can be found.
|
|
func ExtractInReplyToURI(i WithInReplyTo) *url.URL {
|
|
inReplyToProp := i.GetActivityStreamsInReplyTo()
|
|
if inReplyToProp == nil {
|
|
return nil
|
|
}
|
|
|
|
for iter := inReplyToProp.Begin(); iter != inReplyToProp.End(); iter = iter.Next() {
|
|
iri, err := pub.ToId(iter)
|
|
if err == nil && iri != nil {
|
|
// Found one we can use.
|
|
return iri
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ExtractItemsURIs extracts each URI it can
|
|
// find for an item from the provided WithItems.
|
|
func ExtractItemsURIs(i WithItems) []*url.URL {
|
|
itemsProp := i.GetActivityStreamsItems()
|
|
if itemsProp == nil {
|
|
return nil
|
|
}
|
|
|
|
uris := make([]*url.URL, 0, itemsProp.Len())
|
|
for iter := itemsProp.Begin(); iter != itemsProp.End(); iter = iter.Next() {
|
|
uri, err := pub.ToId(iter)
|
|
if err == nil {
|
|
// Found one we can use.
|
|
uris = append(uris, uri)
|
|
}
|
|
}
|
|
|
|
return uris
|
|
}
|
|
|
|
// ExtractToURIs returns a slice of URIs
|
|
// that the given WithTo addresses as To.
|
|
func ExtractToURIs(i WithTo) []*url.URL {
|
|
toProp := i.GetActivityStreamsTo()
|
|
if toProp == nil {
|
|
return nil
|
|
}
|
|
|
|
uris := make([]*url.URL, 0, toProp.Len())
|
|
for iter := toProp.Begin(); iter != toProp.End(); iter = iter.Next() {
|
|
uri, err := pub.ToId(iter)
|
|
if err == nil {
|
|
// Found one we can use.
|
|
uris = append(uris, uri)
|
|
}
|
|
}
|
|
|
|
return uris
|
|
}
|
|
|
|
// ExtractCcURIs returns a slice of URIs
|
|
// that the given WithCC addresses as Cc.
|
|
func ExtractCcURIs(i WithCc) []*url.URL {
|
|
ccProp := i.GetActivityStreamsCc()
|
|
if ccProp == nil {
|
|
return nil
|
|
}
|
|
|
|
urls := make([]*url.URL, 0, ccProp.Len())
|
|
for iter := ccProp.Begin(); iter != ccProp.End(); iter = iter.Next() {
|
|
uri, err := pub.ToId(iter)
|
|
if err == nil {
|
|
// Found one we can use.
|
|
urls = append(urls, uri)
|
|
}
|
|
}
|
|
|
|
return urls
|
|
}
|
|
|
|
// ExtractAttributedToURI returns the first URI it can find in the
|
|
// given WithAttributedTo, or an error if no URI can be found.
|
|
func ExtractAttributedToURI(i WithAttributedTo) (*url.URL, error) {
|
|
attributedToProp := i.GetActivityStreamsAttributedTo()
|
|
if attributedToProp == nil {
|
|
return nil, gtserror.New("attributedToProp was nil")
|
|
}
|
|
|
|
for iter := attributedToProp.Begin(); iter != attributedToProp.End(); iter = iter.Next() {
|
|
id, err := pub.ToId(iter)
|
|
if err == nil {
|
|
return id, nil
|
|
}
|
|
}
|
|
|
|
return nil, gtserror.New("couldn't find iri for attributed to")
|
|
}
|
|
|
|
// ExtractIconURI extracts the first URI it can find from
|
|
// the given WithIcon which links to a supported image file.
|
|
// Input will look something like this:
|
|
//
|
|
// "icon": {
|
|
// "mediaType": "image/jpeg",
|
|
// "type": "Image",
|
|
// "url": "http://example.org/path/to/some/file.jpeg"
|
|
// },
|
|
//
|
|
// If no valid URI can be found, this will return an error.
|
|
func ExtractIconURI(i WithIcon) (*url.URL, error) {
|
|
iconProp := i.GetActivityStreamsIcon()
|
|
if iconProp == nil {
|
|
return nil, gtserror.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 that we can use to derefereince it.
|
|
for iter := iconProp.Begin(); iter != iconProp.End(); iter = iter.Next() {
|
|
if !iter.IsActivityStreamsImage() {
|
|
continue
|
|
}
|
|
|
|
image := iter.GetActivityStreamsImage()
|
|
if image == nil {
|
|
continue
|
|
}
|
|
|
|
imageURL, err := ExtractURL(image)
|
|
if err == nil && imageURL != nil {
|
|
return imageURL, nil
|
|
}
|
|
}
|
|
|
|
return nil, gtserror.New("could not extract valid image URI from icon")
|
|
}
|
|
|
|
// ExtractImageURI extracts the first URI it can find from
|
|
// the given WithImage which links to a supported image file.
|
|
// Input will look something like this:
|
|
//
|
|
// "image": {
|
|
// "mediaType": "image/jpeg",
|
|
// "type": "Image",
|
|
// "url": "http://example.org/path/to/some/file.jpeg"
|
|
// },
|
|
//
|
|
// If no valid URI can be found, this will return an error.
|
|
func ExtractImageURI(i WithImage) (*url.URL, error) {
|
|
imageProp := i.GetActivityStreamsImage()
|
|
if imageProp == nil {
|
|
return nil, gtserror.New("image property was nil")
|
|
}
|
|
|
|
// Image 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 that we can use to derefereince it.
|
|
for iter := imageProp.Begin(); iter != imageProp.End(); iter = iter.Next() {
|
|
if !iter.IsActivityStreamsImage() {
|
|
continue
|
|
}
|
|
|
|
image := iter.GetActivityStreamsImage()
|
|
if image == nil {
|
|
continue
|
|
}
|
|
|
|
imageURL, err := ExtractURL(image)
|
|
if err == nil && imageURL != nil {
|
|
return imageURL, nil
|
|
}
|
|
}
|
|
|
|
return nil, gtserror.New("could not extract valid image URI from image")
|
|
}
|
|
|
|
// ExtractSummary extracts the summary/content warning of
|
|
// the given WithSummary interface. Will return an empty
|
|
// string if no summary/content warning was present.
|
|
func ExtractSummary(i WithSummary) string {
|
|
summaryProp := i.GetActivityStreamsSummary()
|
|
if summaryProp == nil {
|
|
return ""
|
|
}
|
|
|
|
for iter := summaryProp.Begin(); iter != summaryProp.End(); iter = iter.Next() {
|
|
// Summary may be parsed as IRI, depending on
|
|
// how it's formatted, so account for this.
|
|
switch {
|
|
case iter.IsXMLSchemaString():
|
|
return iter.GetXMLSchemaString()
|
|
case iter.IsIRI():
|
|
return iter.GetIRI().String()
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// ExtractFields extracts property/value fields from the given
|
|
// WithAttachment interface. Will return an empty slice if no
|
|
// property/value fields can be found. Attachments that are not
|
|
// (well-formed) PropertyValues will be ignored.
|
|
func ExtractFields(i WithAttachment) []*gtsmodel.Field {
|
|
attachmentProp := i.GetActivityStreamsAttachment()
|
|
if attachmentProp == nil {
|
|
// Nothing to do.
|
|
return nil
|
|
}
|
|
|
|
l := attachmentProp.Len()
|
|
if l == 0 {
|
|
// Nothing to do.
|
|
return nil
|
|
}
|
|
|
|
fields := make([]*gtsmodel.Field, 0, l)
|
|
for iter := attachmentProp.Begin(); iter != attachmentProp.End(); iter = iter.Next() {
|
|
if !iter.IsSchemaPropertyValue() {
|
|
continue
|
|
}
|
|
|
|
propertyValue := iter.GetSchemaPropertyValue()
|
|
if propertyValue == nil {
|
|
continue
|
|
}
|
|
|
|
nameProp := propertyValue.GetActivityStreamsName()
|
|
if nameProp == nil || nameProp.Len() != 1 {
|
|
continue
|
|
}
|
|
|
|
name := nameProp.At(0).GetXMLSchemaString()
|
|
if name == "" {
|
|
continue
|
|
}
|
|
|
|
valueProp := propertyValue.GetSchemaValue()
|
|
if valueProp == nil || !valueProp.IsXMLSchemaString() {
|
|
continue
|
|
}
|
|
|
|
value := valueProp.Get()
|
|
if value == "" {
|
|
continue
|
|
}
|
|
|
|
fields = append(fields, >smodel.Field{
|
|
Name: name,
|
|
Value: value,
|
|
})
|
|
}
|
|
|
|
return fields
|
|
}
|
|
|
|
// ExtractDiscoverable extracts the Discoverable boolean
|
|
// of the given WithDiscoverable interface. Will return
|
|
// an error if Discoverable was nil.
|
|
func ExtractDiscoverable(i WithDiscoverable) (bool, error) {
|
|
discoverableProp := i.GetTootDiscoverable()
|
|
if discoverableProp == nil {
|
|
return false, gtserror.New("discoverable was nil")
|
|
}
|
|
|
|
return discoverableProp.Get(), nil
|
|
}
|
|
|
|
// ExtractURL extracts the first URI it can find from the
|
|
// given WithURL interface, or an error if no URL was set.
|
|
// The ID of a type will not work, this function wants a URI
|
|
// specifically.
|
|
func ExtractURL(i WithURL) (*url.URL, error) {
|
|
urlProp := i.GetActivityStreamsUrl()
|
|
if urlProp == nil {
|
|
return nil, gtserror.New("url property was nil")
|
|
}
|
|
|
|
for iter := urlProp.Begin(); iter != urlProp.End(); iter = iter.Next() {
|
|
if !iter.IsIRI() {
|
|
continue
|
|
}
|
|
|
|
// Found it.
|
|
return iter.GetIRI(), nil
|
|
}
|
|
|
|
return nil, gtserror.New("no valid URL property found")
|
|
}
|
|
|
|
// ExtractPublicKey extracts the public key, public key ID, and public
|
|
// key owner ID from an interface, or an error if something goes wrong.
|
|
func ExtractPublicKey(i WithPublicKey) (
|
|
*rsa.PublicKey, // pubkey
|
|
*url.URL, // pubkey ID
|
|
*url.URL, // pubkey owner
|
|
error,
|
|
) {
|
|
pubKeyProp := i.GetW3IDSecurityV1PublicKey()
|
|
if pubKeyProp == nil {
|
|
return nil, nil, nil, gtserror.New("public key property was nil")
|
|
}
|
|
|
|
for iter := pubKeyProp.Begin(); iter != pubKeyProp.End(); iter = iter.Next() {
|
|
if !iter.IsW3IDSecurityV1PublicKey() {
|
|
continue
|
|
}
|
|
|
|
pkey := iter.Get()
|
|
if pkey == nil {
|
|
continue
|
|
}
|
|
|
|
pubKeyID, err := pub.GetId(pkey)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
pubKeyOwnerProp := pkey.GetW3IDSecurityV1Owner()
|
|
if pubKeyOwnerProp == nil {
|
|
continue
|
|
}
|
|
|
|
pubKeyOwner := pubKeyOwnerProp.GetIRI()
|
|
if pubKeyOwner == nil {
|
|
continue
|
|
}
|
|
|
|
pubKeyPemProp := pkey.GetW3IDSecurityV1PublicKeyPem()
|
|
if pubKeyPemProp == nil {
|
|
continue
|
|
}
|
|
|
|
pkeyPem := pubKeyPemProp.Get()
|
|
if pkeyPem == "" {
|
|
continue
|
|
}
|
|
|
|
block, _ := pem.Decode([]byte(pkeyPem))
|
|
if block == nil {
|
|
continue
|
|
}
|
|
|
|
var p crypto.PublicKey
|
|
switch block.Type {
|
|
case "PUBLIC KEY":
|
|
p, err = x509.ParsePKIXPublicKey(block.Bytes)
|
|
case "RSA PUBLIC KEY":
|
|
p, err = x509.ParsePKCS1PublicKey(block.Bytes)
|
|
default:
|
|
err = fmt.Errorf("unknown block type: %q", block.Type)
|
|
}
|
|
if err != nil {
|
|
err = gtserror.Newf("could not parse public key from block bytes: %w", err)
|
|
return nil, nil, nil, err
|
|
}
|
|
|
|
if p == nil {
|
|
return nil, nil, nil, gtserror.New("returned public key was empty")
|
|
}
|
|
|
|
pubKey, ok := p.(*rsa.PublicKey)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
return pubKey, pubKeyID, pubKeyOwner, nil
|
|
}
|
|
|
|
return nil, nil, nil, gtserror.New("couldn't find public key")
|
|
}
|
|
|
|
// ExtractContent returns an intermediary representation of
|
|
// the given interface's Content and/or ContentMap property.
|
|
func ExtractContent(i WithContent) gtsmodel.Content {
|
|
content := gtsmodel.Content{}
|
|
|
|
contentProp := i.GetActivityStreamsContent()
|
|
if contentProp == nil {
|
|
// No content at all.
|
|
return content
|
|
}
|
|
|
|
for iter := contentProp.Begin(); iter != contentProp.End(); iter = iter.Next() {
|
|
switch {
|
|
case iter.IsRDFLangString() &&
|
|
len(content.ContentMap) == 0:
|
|
content.ContentMap = iter.GetRDFLangString()
|
|
|
|
case iter.IsXMLSchemaString() &&
|
|
content.Content == "":
|
|
content.Content = iter.GetXMLSchemaString()
|
|
|
|
case iter.IsIRI() &&
|
|
content.Content == "":
|
|
content.Content = iter.GetIRI().String()
|
|
}
|
|
}
|
|
|
|
return content
|
|
}
|
|
|
|
// ExtractAttachments attempts to extract barebones MediaAttachment objects from given AS interface type.
|
|
func ExtractAttachments(i WithAttachment) ([]*gtsmodel.MediaAttachment, error) {
|
|
attachmentProp := i.GetActivityStreamsAttachment()
|
|
if attachmentProp == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
var errs gtserror.MultiError
|
|
|
|
attachments := make([]*gtsmodel.MediaAttachment, 0, attachmentProp.Len())
|
|
for iter := attachmentProp.Begin(); iter != attachmentProp.End(); iter = iter.Next() {
|
|
t := iter.GetType()
|
|
if t == nil {
|
|
errs.Appendf("nil attachment type")
|
|
continue
|
|
}
|
|
attachmentable, ok := t.(Attachmentable)
|
|
if !ok {
|
|
errs.Appendf("incorrect attachment type: %T", t)
|
|
continue
|
|
}
|
|
attachment, err := ExtractAttachment(attachmentable)
|
|
if err != nil {
|
|
errs.Appendf("error extracting attachment: %w", err)
|
|
continue
|
|
}
|
|
attachments = append(attachments, attachment)
|
|
}
|
|
|
|
return attachments, errs.Combine()
|
|
}
|
|
|
|
// ExtractAttachment extracts a minimal gtsmodel.Attachment
|
|
// (just remote URL, description, and blurhash) from the given
|
|
// Attachmentable interface, or an error if no remote URL is set.
|
|
func ExtractAttachment(i Attachmentable) (*gtsmodel.MediaAttachment, error) {
|
|
// Get the URL for the attachment file.
|
|
// If no URL is set, we can't do anything.
|
|
remoteURL, err := ExtractURL(i)
|
|
if err != nil {
|
|
return nil, gtserror.Newf("error extracting attachment URL: %w", err)
|
|
}
|
|
|
|
return >smodel.MediaAttachment{
|
|
RemoteURL: remoteURL.String(),
|
|
Description: ExtractDescription(i),
|
|
Blurhash: ExtractBlurhash(i),
|
|
Processing: gtsmodel.ProcessingStatusReceived,
|
|
}, nil
|
|
}
|
|
|
|
// ExtractDescription extracts the image description
|
|
// of an attachmentable, if present. Will try the
|
|
// 'summary' prop first, then fall back to 'name'.
|
|
func ExtractDescription(i Attachmentable) string {
|
|
if summary := ExtractSummary(i); summary != "" {
|
|
return summary
|
|
}
|
|
|
|
return ExtractName(i)
|
|
}
|
|
|
|
// ExtractBlurhash extracts the blurhash string value
|
|
// from the given WithBlurhash interface, or returns
|
|
// an empty string if nothing is found.
|
|
func ExtractBlurhash(i WithBlurhash) string {
|
|
blurhashProp := i.GetTootBlurhash()
|
|
if blurhashProp == nil {
|
|
return ""
|
|
}
|
|
|
|
return blurhashProp.Get()
|
|
}
|
|
|
|
// ExtractHashtags extracts a slice of minimal gtsmodel.Tags
|
|
// from a WithTag. If an entry in the WithTag is not a hashtag,
|
|
// or has a name that cannot be normalized, it will be ignored.
|
|
//
|
|
// TODO: find a better heuristic for determining if something
|
|
// is a hashtag or not, since looking for type name "Hashtag"
|
|
// is non-normative. Perhaps look for things that are either
|
|
// type "Hashtag" or have no type name set at all?
|
|
func ExtractHashtags(i WithTag) ([]*gtsmodel.Tag, error) {
|
|
tagsProp := i.GetActivityStreamsTag()
|
|
if tagsProp == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
var (
|
|
l = tagsProp.Len()
|
|
tags = make([]*gtsmodel.Tag, 0, l)
|
|
keys = make(map[string]any, l) // Use map to dedupe items.
|
|
)
|
|
|
|
for iter := tagsProp.Begin(); iter != tagsProp.End(); iter = iter.Next() {
|
|
t := iter.GetType()
|
|
if t == nil {
|
|
continue
|
|
}
|
|
|
|
if t.GetTypeName() != TagHashtag {
|
|
continue
|
|
}
|
|
|
|
hashtaggable, ok := t.(Hashtaggable)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
tag, err := extractHashtag(hashtaggable)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
// "Normalize" this tag by combining diacritics +
|
|
// unicode chars. If this returns false, it means
|
|
// we couldn't normalize it well enough to make it
|
|
// valid on our instance, so just ignore it.
|
|
normalized, ok := text.NormalizeHashtag(tag.Name)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
// We store tag names lowercased, might
|
|
// as well change case here already.
|
|
tag.Name = strings.ToLower(normalized)
|
|
|
|
// Only append this tag if we haven't
|
|
// seen it already, to avoid duplicates
|
|
// in the slice.
|
|
if _, set := keys[tag.Name]; !set {
|
|
keys[tag.Name] = nil // Value doesn't matter.
|
|
tags = append(tags, tag)
|
|
}
|
|
}
|
|
|
|
return tags, nil
|
|
}
|
|
|
|
// extractHashtag extracts a minimal gtsmodel.Tag from the given
|
|
// Hashtaggable, without yet doing any normalization on it.
|
|
func extractHashtag(i Hashtaggable) (*gtsmodel.Tag, error) {
|
|
// Extract name for the tag; trim leading hash
|
|
// character, so '#example' becomes 'example'.
|
|
name := ExtractName(i)
|
|
if name == "" {
|
|
return nil, gtserror.New("name prop empty")
|
|
}
|
|
tagName := strings.TrimPrefix(name, "#")
|
|
|
|
yeah := func() *bool { t := true; return &t }
|
|
return >smodel.Tag{
|
|
Name: tagName,
|
|
Useable: yeah(), // Assume true by default.
|
|
Listable: yeah(), // Assume true by default.
|
|
}, nil
|
|
}
|
|
|
|
// ExtractEmojis extracts a slice of minimal gtsmodel.Emojis
|
|
// from a WithTag. If an entry in the WithTag is not an emoji,
|
|
// it will be quietly ignored.
|
|
func ExtractEmojis(i WithTag) ([]*gtsmodel.Emoji, error) {
|
|
tagsProp := i.GetActivityStreamsTag()
|
|
if tagsProp == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
var (
|
|
l = tagsProp.Len()
|
|
emojis = make([]*gtsmodel.Emoji, 0, l)
|
|
keys = make(map[string]any, l) // Use map to dedupe items.
|
|
)
|
|
|
|
for iter := tagsProp.Begin(); iter != tagsProp.End(); iter = iter.Next() {
|
|
if !iter.IsTootEmoji() {
|
|
continue
|
|
}
|
|
|
|
tootEmoji := iter.GetTootEmoji()
|
|
if tootEmoji == nil {
|
|
continue
|
|
}
|
|
|
|
emoji, err := ExtractEmoji(tootEmoji)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Only append this emoji if we haven't
|
|
// seen it already, to avoid duplicates
|
|
// in the slice.
|
|
if _, set := keys[emoji.URI]; !set {
|
|
keys[emoji.URI] = nil // Value doesn't matter.
|
|
emojis = append(emojis, emoji)
|
|
}
|
|
}
|
|
|
|
return emojis, nil
|
|
}
|
|
|
|
// ExtractEmoji extracts a minimal gtsmodel.Emoji
|
|
// from the given Emojiable.
|
|
func ExtractEmoji(i Emojiable) (*gtsmodel.Emoji, error) {
|
|
// Use AP ID as emoji URI.
|
|
idProp := i.GetJSONLDId()
|
|
if idProp == nil || !idProp.IsIRI() {
|
|
return nil, gtserror.New("no id for emoji")
|
|
}
|
|
uri := idProp.GetIRI()
|
|
|
|
// Extract emoji last updated time (optional).
|
|
var updatedAt time.Time
|
|
updatedProp := i.GetActivityStreamsUpdated()
|
|
if updatedProp != nil && updatedProp.IsXMLSchemaDateTime() {
|
|
updatedAt = updatedProp.Get()
|
|
}
|
|
|
|
// Extract emoji name aka shortcode.
|
|
name := ExtractName(i)
|
|
if name == "" {
|
|
return nil, gtserror.New("name prop empty")
|
|
}
|
|
shortcode := strings.Trim(name, ":")
|
|
|
|
// Extract emoji image URL from Icon property.
|
|
imageRemoteURL, err := ExtractIconURI(i)
|
|
if err != nil {
|
|
return nil, gtserror.New("no url for emoji image")
|
|
}
|
|
imageRemoteURLStr := imageRemoteURL.String()
|
|
|
|
return >smodel.Emoji{
|
|
UpdatedAt: updatedAt,
|
|
Shortcode: shortcode,
|
|
Domain: uri.Host,
|
|
ImageRemoteURL: imageRemoteURLStr,
|
|
URI: uri.String(),
|
|
Disabled: new(bool), // Assume false by default.
|
|
VisibleInPicker: new(bool), // Assume false by default.
|
|
}, nil
|
|
}
|
|
|
|
// ExtractMentions extracts a slice of minimal gtsmodel.Mentions
|
|
// from a WithTag. If an entry in the WithTag is not a mention,
|
|
// it will be quietly ignored.
|
|
func ExtractMentions(i WithTag) ([]*gtsmodel.Mention, error) {
|
|
tagsProp := i.GetActivityStreamsTag()
|
|
if tagsProp == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
var (
|
|
l = tagsProp.Len()
|
|
mentions = make([]*gtsmodel.Mention, 0, l)
|
|
keys = make(map[string]any, l) // Use map to dedupe items.
|
|
)
|
|
|
|
for iter := tagsProp.Begin(); iter != tagsProp.End(); iter = iter.Next() {
|
|
if !iter.IsActivityStreamsMention() {
|
|
continue
|
|
}
|
|
|
|
asMention := iter.GetActivityStreamsMention()
|
|
if asMention == nil {
|
|
continue
|
|
}
|
|
|
|
mention, err := ExtractMention(asMention)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Only append this mention if we haven't
|
|
// seen it already, to avoid duplicates
|
|
// in the slice.
|
|
if _, set := keys[mention.TargetAccountURI]; !set {
|
|
keys[mention.TargetAccountURI] = nil // Value doesn't matter.
|
|
mentions = append(mentions, mention)
|
|
}
|
|
}
|
|
|
|
return mentions, nil
|
|
}
|
|
|
|
// ExtractMention extracts a minimal gtsmodel.Mention from a Mentionable.
|
|
func ExtractMention(i Mentionable) (*gtsmodel.Mention, error) {
|
|
// See if a name has been set in the
|
|
// format `@someone@example.org`.
|
|
nameString := ExtractName(i)
|
|
|
|
// The href prop should be the AP URI
|
|
// of the target account; it could also
|
|
// be the URL, but we'll check this later.
|
|
var href string
|
|
hrefProp := i.GetActivityStreamsHref()
|
|
if hrefProp != nil && hrefProp.IsIRI() {
|
|
href = hrefProp.GetIRI().String()
|
|
}
|
|
|
|
// One of nameString and hrefProp must be set.
|
|
if nameString == "" && href == "" {
|
|
return nil, gtserror.Newf("neither Name nor Href were set")
|
|
}
|
|
|
|
return >smodel.Mention{
|
|
NameString: nameString,
|
|
TargetAccountURI: href,
|
|
}, nil
|
|
}
|
|
|
|
// ExtractActorURI extracts the first Actor URI
|
|
// it can find from a WithActor interface.
|
|
func ExtractActorURI(withActor WithActor) (*url.URL, error) {
|
|
actorProp := withActor.GetActivityStreamsActor()
|
|
if actorProp == nil {
|
|
return nil, gtserror.New("actor property was nil")
|
|
}
|
|
|
|
for iter := actorProp.Begin(); iter != actorProp.End(); iter = iter.Next() {
|
|
id, err := pub.ToId(iter)
|
|
if err == nil {
|
|
// Found one we can use.
|
|
return id, nil
|
|
}
|
|
}
|
|
|
|
return nil, gtserror.New("no iri found for actor prop")
|
|
}
|
|
|
|
// ExtractObjectURI extracts the first Object URI
|
|
// it can find from a WithObject interface.
|
|
func ExtractObjectURI(withObject WithObject) (*url.URL, error) {
|
|
objectProp := withObject.GetActivityStreamsObject()
|
|
if objectProp == nil {
|
|
return nil, gtserror.New("object property was nil")
|
|
}
|
|
|
|
for iter := objectProp.Begin(); iter != objectProp.End(); iter = iter.Next() {
|
|
id, err := pub.ToId(iter)
|
|
if err == nil {
|
|
// Found one we can use.
|
|
return id, nil
|
|
}
|
|
}
|
|
|
|
return nil, gtserror.New("no iri found for object prop")
|
|
}
|
|
|
|
// ExtractObjectURIs extracts the URLs of each Object
|
|
// it can find from a WithObject interface.
|
|
func ExtractObjectURIs(withObject WithObject) ([]*url.URL, error) {
|
|
objectProp := withObject.GetActivityStreamsObject()
|
|
if objectProp == nil {
|
|
return nil, gtserror.New("object property was nil")
|
|
}
|
|
|
|
urls := make([]*url.URL, 0, objectProp.Len())
|
|
for iter := objectProp.Begin(); iter != objectProp.End(); iter = iter.Next() {
|
|
id, err := pub.ToId(iter)
|
|
if err == nil {
|
|
// Found one we can use.
|
|
urls = append(urls, id)
|
|
}
|
|
}
|
|
|
|
return urls, nil
|
|
}
|
|
|
|
// ExtractVisibility extracts the gtsmodel.Visibility
|
|
// of a given addressable with a To and CC property.
|
|
//
|
|
// ActorFollowersURI is needed to check whether the
|
|
// visibility is FollowersOnly or not. The passed-in
|
|
// value should just be the string value representation
|
|
// of the followers URI of the actor who created the activity,
|
|
// eg., `https://example.org/users/whoever/followers`.
|
|
func ExtractVisibility(addressable Addressable, actorFollowersURI string) (gtsmodel.Visibility, error) {
|
|
var (
|
|
to = ExtractToURIs(addressable)
|
|
cc = ExtractCcURIs(addressable)
|
|
)
|
|
|
|
if len(to) == 0 && len(cc) == 0 {
|
|
return "", gtserror.Newf("message wasn't TO or CC anyone")
|
|
}
|
|
|
|
// Assume most restrictive visibility,
|
|
// and work our way up from there.
|
|
visibility := gtsmodel.VisibilityDirect
|
|
|
|
if isFollowers(to, actorFollowersURI) {
|
|
// Followers in TO: it's at least followers only.
|
|
visibility = gtsmodel.VisibilityFollowersOnly
|
|
}
|
|
|
|
if isPublic(cc) {
|
|
// CC'd to public: it's at least unlocked.
|
|
visibility = gtsmodel.VisibilityUnlocked
|
|
}
|
|
|
|
if isPublic(to) {
|
|
// TO'd to public: it's a public post.
|
|
visibility = gtsmodel.VisibilityPublic
|
|
}
|
|
|
|
return visibility, nil
|
|
}
|
|
|
|
// ExtractSensitive extracts whether or not an item should
|
|
// be marked as sensitive according to its ActivityStreams
|
|
// sensitive property.
|
|
//
|
|
// If no sensitive property is set on the item at all, or
|
|
// if this property isn't a boolean, then false will be
|
|
// returned by default.
|
|
func ExtractSensitive(withSensitive WithSensitive) bool {
|
|
sensitiveProp := withSensitive.GetActivityStreamsSensitive()
|
|
if sensitiveProp == nil {
|
|
return false
|
|
}
|
|
|
|
for iter := sensitiveProp.Begin(); iter != sensitiveProp.End(); iter = iter.Next() {
|
|
if iter.IsXMLSchemaBoolean() {
|
|
return iter.Get()
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// ExtractSharedInbox extracts the sharedInbox URI property
|
|
// from an Actor. Returns nil if this property is not set.
|
|
func ExtractSharedInbox(withEndpoints WithEndpoints) *url.URL {
|
|
endpointsProp := withEndpoints.GetActivityStreamsEndpoints()
|
|
if endpointsProp == nil {
|
|
return nil
|
|
}
|
|
|
|
for iter := endpointsProp.Begin(); iter != endpointsProp.End(); iter = iter.Next() {
|
|
if !iter.IsActivityStreamsEndpoints() {
|
|
continue
|
|
}
|
|
|
|
endpoints := iter.Get()
|
|
if endpoints == nil {
|
|
continue
|
|
}
|
|
|
|
sharedInboxProp := endpoints.GetActivityStreamsSharedInbox()
|
|
if sharedInboxProp == nil || !sharedInboxProp.IsIRI() {
|
|
continue
|
|
}
|
|
|
|
return sharedInboxProp.GetIRI()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ExtractPoll extracts a placeholder Poll from Pollable interface, with available options and flags populated.
|
|
func ExtractPoll(poll Pollable) (*gtsmodel.Poll, error) {
|
|
var closed time.Time
|
|
|
|
// Extract the options (votes if any) and 'multiple choice' flag.
|
|
options, multi, hideCounts, err := extractPollOptions(poll)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Extract the poll closed time,
|
|
// it's okay for this to be zero.
|
|
closedSlice := GetClosed(poll)
|
|
if len(closedSlice) == 1 {
|
|
closed = closedSlice[0]
|
|
}
|
|
|
|
// Extract the poll end time, again
|
|
// this isn't necessarily set as some
|
|
// servers support "endless" polls.
|
|
endTime := GetEndTime(poll)
|
|
|
|
if endTime.IsZero() && !closed.IsZero() {
|
|
// If no endTime is provided, but the
|
|
// poll is marked as closed, infer the
|
|
// endTime from the closed time.
|
|
endTime = closed
|
|
}
|
|
|
|
// Extract the number of voters.
|
|
voters := GetVotersCount(poll)
|
|
|
|
return >smodel.Poll{
|
|
Options: optionNames(options),
|
|
Multiple: &multi,
|
|
HideCounts: &hideCounts,
|
|
Votes: optionVotes(options),
|
|
Voters: &voters,
|
|
ExpiresAt: endTime,
|
|
ClosedAt: closed,
|
|
}, nil
|
|
}
|
|
|
|
// pollOption is a simple type
|
|
// to unify a poll option name
|
|
// with the number of votes.
|
|
type pollOption struct {
|
|
Name string
|
|
Votes int
|
|
}
|
|
|
|
// optionNames extracts name strings from a slice of poll options.
|
|
func optionNames(in []pollOption) []string {
|
|
out := make([]string, len(in))
|
|
for i := range in {
|
|
out[i] = in[i].Name
|
|
}
|
|
return out
|
|
}
|
|
|
|
// optionVotes extracts vote counts from a slice of poll options.
|
|
func optionVotes(in []pollOption) []int {
|
|
out := make([]int, len(in))
|
|
for i := range in {
|
|
out[i] = in[i].Votes
|
|
}
|
|
return out
|
|
}
|
|
|
|
// extractPollOptions extracts poll option name strings, the 'multiple choice flag', and 'hideCounts' intrinsic flag properties value from Pollable.
|
|
func extractPollOptions(poll Pollable) (options []pollOption, multi bool, hide bool, err error) {
|
|
var errs gtserror.MultiError
|
|
|
|
// Iterate the oneOf property and gather poll single-choice options.
|
|
IterateOneOf(poll, func(iter vocab.ActivityStreamsOneOfPropertyIterator) {
|
|
name, votes, err := extractPollOption(iter.GetType())
|
|
if err != nil {
|
|
errs.Append(err)
|
|
return
|
|
}
|
|
if votes == nil {
|
|
hide = true
|
|
votes = new(int)
|
|
}
|
|
options = append(options, pollOption{
|
|
Name: name,
|
|
Votes: *votes,
|
|
})
|
|
})
|
|
if len(options) > 0 || len(errs) > 0 {
|
|
return options, false, hide, errs.Combine()
|
|
}
|
|
|
|
// Iterate the anyOf property and gather poll multi-choice options.
|
|
IterateAnyOf(poll, func(iter vocab.ActivityStreamsAnyOfPropertyIterator) {
|
|
name, votes, err := extractPollOption(iter.GetType())
|
|
if err != nil {
|
|
errs.Append(err)
|
|
return
|
|
}
|
|
if votes == nil {
|
|
hide = true
|
|
votes = new(int)
|
|
}
|
|
options = append(options, pollOption{
|
|
Name: name,
|
|
Votes: *votes,
|
|
})
|
|
})
|
|
if len(options) > 0 || len(errs) > 0 {
|
|
return options, true, hide, errs.Combine()
|
|
}
|
|
|
|
return nil, false, false, errors.New("poll without options")
|
|
}
|
|
|
|
// IterateOneOf will attempt to extract oneOf property from given interface, and passes each iterated item to function.
|
|
func IterateOneOf(withOneOf WithOneOf, foreach func(vocab.ActivityStreamsOneOfPropertyIterator)) {
|
|
if foreach == nil {
|
|
// nil check outside loop.
|
|
panic("nil function")
|
|
}
|
|
|
|
// Extract the one-of property from interface.
|
|
oneOfProp := withOneOf.GetActivityStreamsOneOf()
|
|
if oneOfProp == nil {
|
|
return
|
|
}
|
|
|
|
// Get start and end of iter.
|
|
start := oneOfProp.Begin()
|
|
end := oneOfProp.End()
|
|
|
|
// Pass iterated oneOf entries to given function.
|
|
for iter := start; iter != end; iter = iter.Next() {
|
|
foreach(iter)
|
|
}
|
|
}
|
|
|
|
// IterateAnyOf will attempt to extract anyOf property from given interface, and passes each iterated item to function.
|
|
func IterateAnyOf(withAnyOf WithAnyOf, foreach func(vocab.ActivityStreamsAnyOfPropertyIterator)) {
|
|
if foreach == nil {
|
|
// nil check outside loop.
|
|
panic("nil function")
|
|
}
|
|
|
|
// Extract the any-of property from interface.
|
|
anyOfProp := withAnyOf.GetActivityStreamsAnyOf()
|
|
if anyOfProp == nil {
|
|
return
|
|
}
|
|
|
|
// Get start and end of iter.
|
|
start := anyOfProp.Begin()
|
|
end := anyOfProp.End()
|
|
|
|
// Pass iterated anyOf entries to given function.
|
|
for iter := start; iter != end; iter = iter.Next() {
|
|
foreach(iter)
|
|
}
|
|
}
|
|
|
|
// extractPollOption extracts a usable poll option name from vocab.Type, or error.
|
|
func extractPollOption(t vocab.Type) (name string, votes *int, err error) {
|
|
// Check fulfills PollOptionable type
|
|
// (this accounts for nil input type).
|
|
optionable, ok := t.(PollOptionable)
|
|
if !ok {
|
|
return "", nil, fmt.Errorf("incorrect option type: %T", t)
|
|
}
|
|
|
|
// Extract PollOption from interface.
|
|
name = ExtractName(optionable)
|
|
if name == "" {
|
|
return "", nil, errors.New("empty option name")
|
|
}
|
|
|
|
// Check PollOptionable for attached 'replies' property.
|
|
repliesProp := optionable.GetActivityStreamsReplies()
|
|
if repliesProp != nil {
|
|
|
|
// Get repliesProp as the AS collection type it should be.
|
|
collection := repliesProp.GetActivityStreamsCollection()
|
|
if collection != nil {
|
|
|
|
// Extract integer value from the collection 'totalItems' property.
|
|
totalItemsProp := collection.GetActivityStreamsTotalItems()
|
|
if totalItemsProp != nil {
|
|
i := totalItemsProp.Get()
|
|
votes = &i
|
|
}
|
|
}
|
|
}
|
|
|
|
return name, votes, nil
|
|
}
|
|
|
|
// isPublic checks if at least one entry in the given
|
|
// uris slice equals the activitystreams public uri.
|
|
func isPublic(uris []*url.URL) bool {
|
|
for _, uri := range uris {
|
|
if pub.IsPublic(uri.String()) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// isFollowers checks if at least one entry in the given
|
|
// uris slice equals the given followersURI.
|
|
func isFollowers(uris []*url.URL, followersURI string) bool {
|
|
for _, uri := range uris {
|
|
if strings.EqualFold(uri.String(), followersURI) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|