[feature] Hashtag federation (in/out), hashtag client API endpoints (#2032)

* update go-fed

* do the things

* remove unused columns from tags

* update to latest lingo from main

* further tag shenanigans

* serve stub page at tag endpoint

* we did it lads

* tests, oh tests, ohhh tests, oh tests (doo doo doo doo)

* swagger docs

* document hashtag usage + federation

* instanceGet

* don't bother parsing tag href

* rename whereStartsWith -> whereStartsLike

* remove GetOrCreateTag

* dont cache status tag timelineability
This commit is contained in:
tobi 2023-07-31 15:47:35 +02:00 committed by GitHub
commit 2796a2e82f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
69 changed files with 2536 additions and 482 deletions

View file

@ -71,7 +71,8 @@ type TypeConverter interface {
// EmojiCategoryToAPIEmojiCategory converts a gts model emoji category into its api (frontend) representation.
EmojiCategoryToAPIEmojiCategory(ctx context.Context, category *gtsmodel.EmojiCategory) (*apimodel.EmojiCategory, error)
// TagToAPITag converts a gts model tag into its api (frontend) representation for serialization on the API.
TagToAPITag(ctx context.Context, t *gtsmodel.Tag) (apimodel.Tag, error)
// If stubHistory is set to 'true', then the 'history' field of the tag will be populated with a pointer to an empty slice, for API compatibility reasons.
TagToAPITag(ctx context.Context, t *gtsmodel.Tag, stubHistory bool) (apimodel.Tag, error)
// StatusToAPIStatus converts a gts model status into its api (frontend) representation for serialization on the API.
//
// Requesting account can be nil.
@ -160,6 +161,8 @@ type TypeConverter interface {
MentionToAS(ctx context.Context, m *gtsmodel.Mention) (vocab.ActivityStreamsMention, error)
// EmojiToAS converts a gts emoji into a mastodon ns Emoji, suitable for federation
EmojiToAS(ctx context.Context, e *gtsmodel.Emoji) (vocab.TootEmoji, error)
// TagToAS converts a gts model tag into a toot Hashtag, suitable for federation.
TagToAS(ctx context.Context, t *gtsmodel.Tag) (vocab.TootHashtag, error)
// AttachmentToAS converts a gts model media attachment into an activity streams Attachment, suitable for federation
AttachmentToAS(ctx context.Context, a *gtsmodel.MediaAttachment) (vocab.ActivityStreamsDocument, error)
// FaveToAS converts a gts model status fave into an activityStreams LIKE, suitable for federation.

View file

@ -24,6 +24,7 @@ import (
"errors"
"fmt"
"net/url"
"strings"
"github.com/superseriousbusiness/activity/pub"
"github.com/superseriousbusiness/activity/streams"
@ -33,6 +34,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/uris"
)
// Converts a gts model account into an Activity Streams person type.
@ -407,7 +409,7 @@ func (c *converter) StatusToAS(ctx context.Context, s *gtsmodel.Status) (vocab.A
if s.Account == nil {
a, err := c.db.GetAccountByID(ctx, s.AccountID)
if err != nil {
return nil, fmt.Errorf("StatusToAS: error retrieving author account from db: %s", err)
return nil, gtserror.Newf("error retrieving author account from db: %w", err)
}
s.Account = a
}
@ -418,7 +420,7 @@ func (c *converter) StatusToAS(ctx context.Context, s *gtsmodel.Status) (vocab.A
// id
statusURI, err := url.Parse(s.URI)
if err != nil {
return nil, fmt.Errorf("StatusToAS: error parsing url %s: %s", s.URI, err)
return nil, gtserror.Newf("error parsing url %s: %w", s.URI, err)
}
statusIDProp := streams.NewJSONLDIdProperty()
statusIDProp.SetIRI(statusURI)
@ -436,7 +438,7 @@ func (c *converter) StatusToAS(ctx context.Context, s *gtsmodel.Status) (vocab.A
if s.InReplyToURI != "" {
rURI, err := url.Parse(s.InReplyToURI)
if err != nil {
return nil, fmt.Errorf("StatusToAS: error parsing url %s: %s", s.InReplyToURI, err)
return nil, gtserror.Newf("error parsing url %s: %w", s.InReplyToURI, err)
}
inReplyToProp := streams.NewActivityStreamsInReplyToProperty()
@ -453,7 +455,7 @@ func (c *converter) StatusToAS(ctx context.Context, s *gtsmodel.Status) (vocab.A
if s.URL != "" {
sURL, err := url.Parse(s.URL)
if err != nil {
return nil, fmt.Errorf("StatusToAS: error parsing url %s: %s", s.URL, err)
return nil, gtserror.Newf("error parsing url %s: %w", s.URL, err)
}
urlProp := streams.NewActivityStreamsUrlProperty()
@ -464,7 +466,7 @@ func (c *converter) StatusToAS(ctx context.Context, s *gtsmodel.Status) (vocab.A
// attributedTo
authorAccountURI, err := url.Parse(s.Account.URI)
if err != nil {
return nil, fmt.Errorf("StatusToAS: error parsing url %s: %s", s.Account.URI, err)
return nil, gtserror.Newf("error parsing url %s: %w", s.Account.URI, err)
}
attributedToProp := streams.NewActivityStreamsAttributedToProperty()
attributedToProp.AppendIRI(authorAccountURI)
@ -478,13 +480,13 @@ func (c *converter) StatusToAS(ctx context.Context, s *gtsmodel.Status) (vocab.A
if len(s.MentionIDs) > len(mentions) {
mentions, err = c.db.GetMentions(ctx, s.MentionIDs)
if err != nil {
return nil, fmt.Errorf("StatusToAS: error getting mentions: %w", err)
return nil, gtserror.Newf("error getting mentions: %w", err)
}
}
for _, m := range mentions {
asMention, err := c.MentionToAS(ctx, m)
if err != nil {
return nil, fmt.Errorf("StatusToAS: error converting mention to AS mention: %s", err)
return nil, gtserror.Newf("error converting mention to AS mention: %w", err)
}
tagProp.AppendActivityStreamsMention(asMention)
}
@ -496,7 +498,7 @@ func (c *converter) StatusToAS(ctx context.Context, s *gtsmodel.Status) (vocab.A
for _, emojiID := range s.EmojiIDs {
emoji, err := c.db.GetEmojiByID(ctx, emojiID)
if err != nil {
return nil, fmt.Errorf("StatusToAS: error getting emoji %s from database: %s", emojiID, err)
return nil, gtserror.Newf("error getting emoji %s from database: %w", emojiID, err)
}
emojis = append(emojis, emoji)
}
@ -504,25 +506,38 @@ func (c *converter) StatusToAS(ctx context.Context, s *gtsmodel.Status) (vocab.A
for _, emoji := range emojis {
asEmoji, err := c.EmojiToAS(ctx, emoji)
if err != nil {
return nil, fmt.Errorf("StatusToAS: error converting emoji to AS emoji: %s", err)
return nil, gtserror.Newf("error converting emoji to AS emoji: %w", err)
}
tagProp.AppendTootEmoji(asEmoji)
}
// tag -- hashtags
// TODO
hashtags := s.Tags
if len(s.TagIDs) > len(hashtags) {
hashtags, err = c.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, fmt.Errorf("StatusToAS: error parsing url %s: %s", s.Account.FollowersURI, err)
return nil, gtserror.Newf("error parsing url %s: %w", s.Account.FollowersURI, err)
}
publicURI, err := url.Parse(pub.PublicActivityPubIRI)
if err != nil {
return nil, fmt.Errorf("StatusToAS: error parsing url %s: %s", pub.PublicActivityPubIRI, err)
return nil, gtserror.Newf("error parsing url %s: %w", pub.PublicActivityPubIRI, err)
}
// to and cc
@ -534,7 +549,7 @@ func (c *converter) StatusToAS(ctx context.Context, s *gtsmodel.Status) (vocab.A
for _, m := range mentions {
iri, err := url.Parse(m.TargetAccount.URI)
if err != nil {
return nil, fmt.Errorf("StatusToAS: error parsing uri %s: %s", m.TargetAccount.URI, err)
return nil, gtserror.Newf("error parsing uri %s: %w", m.TargetAccount.URI, err)
}
toProp.AppendIRI(iri)
}
@ -546,7 +561,7 @@ func (c *converter) StatusToAS(ctx context.Context, s *gtsmodel.Status) (vocab.A
for _, m := range mentions {
iri, err := url.Parse(m.TargetAccount.URI)
if err != nil {
return nil, fmt.Errorf("StatusToAS: error parsing uri %s: %s", m.TargetAccount.URI, err)
return nil, gtserror.Newf("error parsing uri %s: %w", m.TargetAccount.URI, err)
}
ccProp.AppendIRI(iri)
}
@ -557,7 +572,7 @@ func (c *converter) StatusToAS(ctx context.Context, s *gtsmodel.Status) (vocab.A
for _, m := range mentions {
iri, err := url.Parse(m.TargetAccount.URI)
if err != nil {
return nil, fmt.Errorf("StatusToAS: error parsing uri %s: %s", m.TargetAccount.URI, err)
return nil, gtserror.Newf("error parsing uri %s: %w", m.TargetAccount.URI, err)
}
ccProp.AppendIRI(iri)
}
@ -568,7 +583,7 @@ func (c *converter) StatusToAS(ctx context.Context, s *gtsmodel.Status) (vocab.A
for _, m := range mentions {
iri, err := url.Parse(m.TargetAccount.URI)
if err != nil {
return nil, fmt.Errorf("StatusToAS: error parsing uri %s: %s", m.TargetAccount.URI, err)
return nil, gtserror.Newf("error parsing uri %s: %w", m.TargetAccount.URI, err)
}
ccProp.AppendIRI(iri)
}
@ -592,7 +607,7 @@ func (c *converter) StatusToAS(ctx context.Context, s *gtsmodel.Status) (vocab.A
for _, attachmentID := range s.AttachmentIDs {
attachment, err := c.db.GetAttachmentByID(ctx, attachmentID)
if err != nil {
return nil, fmt.Errorf("StatusToAS: error getting attachment %s from database: %s", attachmentID, err)
return nil, gtserror.Newf("error getting attachment %s from database: %w", attachmentID, err)
}
attachments = append(attachments, attachment)
}
@ -600,7 +615,7 @@ func (c *converter) StatusToAS(ctx context.Context, s *gtsmodel.Status) (vocab.A
for _, a := range attachments {
doc, err := c.AttachmentToAS(ctx, a)
if err != nil {
return nil, fmt.Errorf("StatusToAS: error converting attachment: %s", err)
return nil, gtserror.Newf("error converting attachment: %w", err)
}
attachmentProp.AppendActivityStreamsDocument(doc)
}
@ -609,7 +624,7 @@ func (c *converter) StatusToAS(ctx context.Context, s *gtsmodel.Status) (vocab.A
// replies
repliesCollection, err := c.StatusToASRepliesCollection(ctx, s, false)
if err != nil {
return nil, fmt.Errorf("error creating repliesCollection: %s", err)
return nil, fmt.Errorf("error creating repliesCollection: %w", err)
}
repliesProp := streams.NewActivityStreamsRepliesProperty()
@ -846,6 +861,32 @@ func (c *converter) MentionToAS(ctx context.Context, m *gtsmodel.Mention) (vocab
return mention, nil
}
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.GenerateURIForTag(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
}
/*
we're making something like this:
{

View file

@ -403,17 +403,24 @@ func (suite *InternalToASTestSuite) TestStatusWithTagsToASWithIDs() {
},
"sensitive": false,
"summary": "",
"tag": {
"icon": {
"mediaType": "image/png",
"type": "Image",
"url": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png"
"tag": [
{
"icon": {
"mediaType": "image/png",
"type": "Image",
"url": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png"
},
"id": "http://localhost:8080/emoji/01F8MH9H8E4VG3KDYJR9EGPXCQ",
"name": ":rainbow:",
"type": "Emoji",
"updated": "2021-09-20T10:40:37Z"
},
"id": "http://localhost:8080/emoji/01F8MH9H8E4VG3KDYJR9EGPXCQ",
"name": ":rainbow:",
"type": "Emoji",
"updated": "2021-09-20T10:40:37Z"
},
{
"href": "http://localhost:8080/tags/welcome",
"name": "#welcome",
"type": "Hashtag"
}
],
"to": "https://www.w3.org/ns/activitystreams#Public",
"type": "Note",
"url": "http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R"
@ -463,17 +470,24 @@ func (suite *InternalToASTestSuite) TestStatusWithTagsToASFromDB() {
},
"sensitive": false,
"summary": "",
"tag": {
"icon": {
"mediaType": "image/png",
"type": "Image",
"url": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png"
"tag": [
{
"icon": {
"mediaType": "image/png",
"type": "Image",
"url": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png"
},
"id": "http://localhost:8080/emoji/01F8MH9H8E4VG3KDYJR9EGPXCQ",
"name": ":rainbow:",
"type": "Emoji",
"updated": "2021-09-20T10:40:37Z"
},
"id": "http://localhost:8080/emoji/01F8MH9H8E4VG3KDYJR9EGPXCQ",
"name": ":rainbow:",
"type": "Emoji",
"updated": "2021-09-20T10:40:37Z"
},
{
"href": "http://localhost:8080/tags/welcome",
"name": "#welcome",
"type": "Hashtag"
}
],
"to": "https://www.w3.org/ns/activitystreams#Public",
"type": "Note",
"url": "http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R"

View file

@ -32,6 +32,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/uris"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
@ -568,10 +569,18 @@ func (c *converter) EmojiCategoryToAPIEmojiCategory(ctx context.Context, categor
}, nil
}
func (c *converter) TagToAPITag(ctx context.Context, t *gtsmodel.Tag) (apimodel.Tag, error) {
func (c *converter) TagToAPITag(ctx context.Context, t *gtsmodel.Tag, stubHistory bool) (apimodel.Tag, error) {
return apimodel.Tag{
Name: t.Name,
URL: t.URL,
Name: strings.ToLower(t.Name),
URL: uris.GenerateURIForTag(t.Name),
History: func() *[]any {
if !stubHistory {
return nil
}
h := make([]any, 0)
return &h
}(),
}, nil
}
@ -1297,19 +1306,11 @@ func (c *converter) convertTagsToAPITags(ctx context.Context, tags []*gtsmodel.T
var errs gtserror.MultiError
if len(tags) == 0 {
// GTS model tags were not populated
var err error
// Preallocate expected GTS slice
tags = make([]*gtsmodel.Tag, 0, len(tagIDs))
// Fetch GTS models for tag IDs
for _, id := range tagIDs {
tag := new(gtsmodel.Tag)
if err := c.db.GetByID(ctx, id, tag); err != nil {
errs.Appendf("error fetching tag %s from database: %v", id, err)
continue
}
tags = append(tags, tag)
tags, err = c.db.GetTags(ctx, tagIDs)
if err != nil {
errs.Appendf("error fetching tags from database: %v", err)
}
}
@ -1318,7 +1319,7 @@ func (c *converter) convertTagsToAPITags(ctx context.Context, tags []*gtsmodel.T
// Convert GTS models to frontend models
for _, tag := range tags {
apiTag, err := c.TagToAPITag(ctx, tag)
apiTag, err := c.TagToAPITag(ctx, tag, false)
if err != nil {
errs.Appendf("error converting tag %s to api tag: %v", tag.ID, err)
continue