summaryrefslogtreecommitdiff
path: root/internal/typeutils
diff options
context:
space:
mode:
Diffstat (limited to 'internal/typeutils')
-rw-r--r--internal/typeutils/internaltoas.go2
-rw-r--r--internal/typeutils/internaltofrontend.go105
-rw-r--r--internal/typeutils/internaltofrontend_test.go106
-rw-r--r--internal/typeutils/util.go84
4 files changed, 266 insertions, 31 deletions
diff --git a/internal/typeutils/internaltoas.go b/internal/typeutils/internaltoas.go
index a668989e6..541e2f4d1 100644
--- a/internal/typeutils/internaltoas.go
+++ b/internal/typeutils/internaltoas.go
@@ -954,7 +954,7 @@ func (c *Converter) TagToAS(ctx context.Context, t *gtsmodel.Tag) (vocab.TootHas
// This is probably already lowercase,
// but let's err on the safe side.
nameLower := strings.ToLower(t.Name)
- tagURLString := uris.GenerateURIForTag(nameLower)
+ tagURLString := uris.URIForTag(nameLower)
// Create the tag.
tag := streams.NewTootHashtag()
diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go
index d5a1dee32..a7bcddac6 100644
--- a/internal/typeutils/internaltofrontend.go
+++ b/internal/typeutils/internaltofrontend.go
@@ -434,11 +434,14 @@ func (c *Converter) AppToAPIAppPublic(ctx context.Context, a *gtsmodel.Applicati
// AttachmentToAPIAttachment converts a gts model media attacahment into its api representation for serialization on the API.
func (c *Converter) AttachmentToAPIAttachment(ctx context.Context, a *gtsmodel.MediaAttachment) (apimodel.Attachment, error) {
apiAttachment := apimodel.Attachment{
- ID: a.ID,
- Type: strings.ToLower(string(a.Type)),
- TextURL: a.URL,
- PreviewURL: a.Thumbnail.URL,
- Meta: apimodel.MediaMeta{
+ ID: a.ID,
+ Type: strings.ToLower(string(a.Type)),
+ }
+
+ // Don't try to serialize meta for
+ // unknown attachments, there's no point.
+ if a.Type != gtsmodel.FileTypeUnknown {
+ apiAttachment.Meta = &apimodel.MediaMeta{
Original: apimodel.MediaDimensions{
Width: a.FileMeta.Original.Width,
Height: a.FileMeta.Original.Height,
@@ -449,13 +452,20 @@ func (c *Converter) AttachmentToAPIAttachment(ctx context.Context, a *gtsmodel.M
Size: strconv.Itoa(a.FileMeta.Small.Width) + "x" + strconv.Itoa(a.FileMeta.Small.Height),
Aspect: float32(a.FileMeta.Small.Aspect),
},
- },
- Blurhash: a.Blurhash,
+ }
+ }
+
+ if i := a.Blurhash; i != "" {
+ apiAttachment.Blurhash = &i
}
- // nullable fields
if i := a.URL; i != "" {
apiAttachment.URL = &i
+ apiAttachment.TextURL = &i
+ }
+
+ if i := a.Thumbnail.URL; i != "" {
+ apiAttachment.PreviewURL = &i
}
if i := a.RemoteURL; i != "" {
@@ -470,8 +480,9 @@ func (c *Converter) AttachmentToAPIAttachment(ctx context.Context, a *gtsmodel.M
apiAttachment.Description = &i
}
- // type specific fields
+ // Type-specific fields.
switch a.Type {
+
case gtsmodel.FileTypeImage:
apiAttachment.Meta.Original.Size = strconv.Itoa(a.FileMeta.Original.Width) + "x" + strconv.Itoa(a.FileMeta.Original.Height)
apiAttachment.Meta.Original.Aspect = float32(a.FileMeta.Original.Aspect)
@@ -479,16 +490,17 @@ func (c *Converter) AttachmentToAPIAttachment(ctx context.Context, a *gtsmodel.M
X: a.FileMeta.Focus.X,
Y: a.FileMeta.Focus.Y,
}
+
case gtsmodel.FileTypeVideo:
if i := a.FileMeta.Original.Duration; i != nil {
apiAttachment.Meta.Original.Duration = *i
}
if i := a.FileMeta.Original.Framerate; i != nil {
- // the masto api expects this as a string in
- // the format `integer/1`, so 30fps is `30/1`
+ // The masto api expects this as a string in
+ // the format `integer/1`, so 30fps is `30/1`.
round := math.Round(float64(*i))
- fr := strconv.FormatInt(int64(round), 10)
+ fr := strconv.Itoa(int(round))
apiAttachment.Meta.Original.FrameRate = fr + "/1"
}
@@ -599,7 +611,7 @@ func (c *Converter) EmojiCategoryToAPIEmojiCategory(ctx context.Context, categor
func (c *Converter) TagToAPITag(ctx context.Context, t *gtsmodel.Tag, stubHistory bool) (apimodel.Tag, error) {
return apimodel.Tag{
Name: strings.ToLower(t.Name),
- URL: uris.GenerateURIForTag(t.Name),
+ URL: uris.URIForTag(t.Name),
History: func() *[]any {
if !stubHistory {
return nil
@@ -611,15 +623,56 @@ func (c *Converter) TagToAPITag(ctx context.Context, t *gtsmodel.Tag, stubHistor
}, nil
}
-// StatusToAPIStatus converts a gts model status into its api (frontend) representation for serialization on the API.
+// StatusToAPIStatus converts a gts model status into its api
+// (frontend) representation for serialization on the API.
+//
+// Requesting account can be nil.
+func (c *Converter) StatusToAPIStatus(
+ ctx context.Context,
+ s *gtsmodel.Status,
+ requestingAccount *gtsmodel.Account,
+) (*apimodel.Status, error) {
+ apiStatus, err := c.statusToFrontend(ctx, s, requestingAccount)
+ if err != nil {
+ return nil, err
+ }
+
+ // Normalize status for the API by pruning
+ // out unknown attachment types and replacing
+ // them with a helpful message.
+ var aside string
+ aside, apiStatus.MediaAttachments = placeholdUnknownAttachments(apiStatus.MediaAttachments)
+ apiStatus.Content += aside
+
+ return apiStatus, nil
+}
+
+// StatusToWebStatus converts a gts model status into an
+// api representation suitable for serving into a web template.
//
// Requesting account can be nil.
-func (c *Converter) StatusToAPIStatus(ctx context.Context, s *gtsmodel.Status, requestingAccount *gtsmodel.Account) (*apimodel.Status, error) {
+func (c *Converter) StatusToWebStatus(
+ ctx context.Context,
+ s *gtsmodel.Status,
+ requestingAccount *gtsmodel.Account,
+) (*apimodel.Status, error) {
+ return c.statusToFrontend(ctx, s, requestingAccount)
+}
+
+// statusToFrontend is a package internal function for
+// parsing a status into its initial frontend representation.
+//
+// Requesting account can be nil.
+func (c *Converter) statusToFrontend(
+ ctx context.Context,
+ s *gtsmodel.Status,
+ requestingAccount *gtsmodel.Account,
+) (*apimodel.Status, error) {
if err := c.state.DB.PopulateStatus(ctx, s); err != nil {
// Ensure author account present + correct;
// can't really go further without this!
if s.Account == nil {
- return nil, fmt.Errorf("error(s) populating status, cannot continue: %w", err)
+ return nil, gtserror.Newf("error(s) populating status, cannot continue: %w", err)
}
log.Errorf(ctx, "error(s) populating status, will continue: %v", err)
@@ -627,22 +680,22 @@ func (c *Converter) StatusToAPIStatus(ctx context.Context, s *gtsmodel.Status, r
apiAuthorAccount, err := c.AccountToAPIAccountPublic(ctx, s.Account)
if err != nil {
- return nil, fmt.Errorf("error converting status author: %w", err)
+ return nil, gtserror.Newf("error converting status author: %w", err)
}
repliesCount, err := c.state.DB.CountStatusReplies(ctx, s.ID)
if err != nil {
- return nil, fmt.Errorf("error counting replies: %w", err)
+ return nil, gtserror.Newf("error counting replies: %w", err)
}
reblogsCount, err := c.state.DB.CountStatusBoosts(ctx, s.ID)
if err != nil {
- return nil, fmt.Errorf("error counting reblogs: %w", err)
+ return nil, gtserror.Newf("error counting reblogs: %w", err)
}
favesCount, err := c.state.DB.CountStatusFaves(ctx, s.ID)
if err != nil {
- return nil, fmt.Errorf("error counting faves: %w", err)
+ return nil, gtserror.Newf("error counting faves: %w", err)
}
interacts, err := c.interactionsWithStatusForAccount(ctx, s, requestingAccount)
@@ -722,7 +775,7 @@ func (c *Converter) StatusToAPIStatus(ctx context.Context, s *gtsmodel.Status, r
if s.BoostOf != nil {
apiBoostOf, err := c.StatusToAPIStatus(ctx, s.BoostOf, requestingAccount)
if err != nil {
- return nil, fmt.Errorf("error converting boosted status: %w", err)
+ return nil, gtserror.Newf("error converting boosted status: %w", err)
}
apiStatus.Reblog = &apimodel.StatusReblogged{Status: apiBoostOf}
@@ -733,13 +786,13 @@ func (c *Converter) StatusToAPIStatus(ctx context.Context, s *gtsmodel.Status, r
if app == nil {
app, err = c.state.DB.GetApplicationByID(ctx, appID)
if err != nil {
- return nil, fmt.Errorf("error getting application %s: %w", appID, err)
+ return nil, gtserror.Newf("error getting application %s: %w", appID, err)
}
}
apiApp, err := c.AppToAPIAppPublic(ctx, app)
if err != nil {
- return nil, fmt.Errorf("error converting application %s: %w", appID, err)
+ return nil, gtserror.Newf("error converting application %s: %w", appID, err)
}
apiStatus.Application = apiApp
@@ -757,11 +810,9 @@ func (c *Converter) StatusToAPIStatus(ctx context.Context, s *gtsmodel.Status, r
}
}
- // Normalization.
-
+ // If web URL is empty for whatever
+ // reason, provide AP URI as fallback.
if s.URL == "" {
- // URL was empty for some reason;
- // provide AP URI as fallback.
s.URL = s.URI
}
diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go
index 0e09faeea..0e5d3a45b 100644
--- a/internal/typeutils/internaltofrontend_test.go
+++ b/internal/typeutils/internaltofrontend_test.go
@@ -344,7 +344,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() {
"language": "en",
"uri": "http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R",
"url": "http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R",
- "replies_count": 0,
+ "replies_count": 1,
"reblogs_count": 0,
"favourites_count": 1,
"favourited": true,
@@ -437,6 +437,105 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() {
}`, string(b))
}
+func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownAttachments() {
+ testStatus := suite.testStatuses["remote_account_2_status_1"]
+ requestingAccount := suite.testAccounts["admin_account"]
+
+ apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount)
+ suite.NoError(err)
+
+ b, err := json.MarshalIndent(apiStatus, "", " ")
+ suite.NoError(err)
+
+ suite.Equal(`{
+ "id": "01HE7XJ1CG84TBKH5V9XKBVGF5",
+ "created_at": "2023-11-02T10:44:25.000Z",
+ "in_reply_to_id": "01F8MH75CBF9JFX4ZAD54N0W0R",
+ "in_reply_to_account_id": "01F8MH17FWEB39HZJ76B6VXSKF",
+ "sensitive": false,
+ "spoiler_text": "",
+ "visibility": "public",
+ "language": "en",
+ "uri": "http://example.org/users/Some_User/statuses/01HE7XJ1CG84TBKH5V9XKBVGF5",
+ "url": "http://example.org/@Some_User/statuses/01HE7XJ1CG84TBKH5V9XKBVGF5",
+ "replies_count": 0,
+ "reblogs_count": 0,
+ "favourites_count": 0,
+ "favourited": false,
+ "reblogged": false,
+ "muted": false,
+ "bookmarked": false,
+ "pinned": false,
+ "content": "\u003cp\u003ehi \u003cspan class=\"h-card\"\u003e\u003ca href=\"http://localhost:8080/@admin\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e@\u003cspan\u003eadmin\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e here's some media for ya\u003c/p\u003e\u003caside\u003e\u003cp\u003eNote from localhost:8080: 2 attachments in this status could not be downloaded. Treat the following external links with care:\u003cul\u003e\u003cli\u003e\u003ca href=\"http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE7ZGJYTSYMXF927GF9353KR.svg\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e01HE7ZGJYTSYMXF927GF9353KR.svg\u003c/a\u003e [SVG line art of a sloth, public domain]\u003c/li\u003e\u003cli\u003e\u003ca href=\"http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE892Y8ZS68TQCNPX7J888P3.mp3\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e01HE892Y8ZS68TQCNPX7J888P3.mp3\u003c/a\u003e [Jolly salsa song, public domain.]\u003c/li\u003e\u003c/ul\u003e\u003c/p\u003e\u003c/aside\u003e",
+ "reblog": null,
+ "account": {
+ "id": "01FHMQX3GAABWSM0S2VZEC2SWC",
+ "username": "Some_User",
+ "acct": "Some_User@example.org",
+ "display_name": "some user",
+ "locked": true,
+ "discoverable": true,
+ "bot": false,
+ "created_at": "2020-08-10T12:13:28.000Z",
+ "note": "i'm a real son of a gun",
+ "url": "http://example.org/@Some_User",
+ "avatar": "",
+ "avatar_static": "",
+ "header": "http://localhost:8080/assets/default_header.png",
+ "header_static": "http://localhost:8080/assets/default_header.png",
+ "followers_count": 0,
+ "following_count": 0,
+ "statuses_count": 1,
+ "last_status_at": "2023-11-02T10:44:25.000Z",
+ "emojis": [],
+ "fields": []
+ },
+ "media_attachments": [
+ {
+ "id": "01HE7Y3C432WRSNS10EZM86SA5",
+ "type": "image",
+ "url": "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE7Y3C432WRSNS10EZM86SA5.jpg",
+ "text_url": "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE7Y3C432WRSNS10EZM86SA5.jpg",
+ "preview_url": "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/small/01HE7Y3C432WRSNS10EZM86SA5.jpg",
+ "remote_url": "http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE7Y6G0EMCKST3Q0914WW0MS.jpg",
+ "preview_remote_url": null,
+ "meta": {
+ "original": {
+ "width": 3000,
+ "height": 2000,
+ "size": "3000x2000",
+ "aspect": 1.5
+ },
+ "small": {
+ "width": 512,
+ "height": 341,
+ "size": "512x341",
+ "aspect": 1.5014663
+ },
+ "focus": {
+ "x": 0,
+ "y": 0
+ }
+ },
+ "description": "Photograph of a sloth, Public Domain.",
+ "blurhash": "LNEC{|w}0K9GsEtPM|j[NFbHoeof"
+ }
+ ],
+ "mentions": [
+ {
+ "id": "01F8MH17FWEB39HZJ76B6VXSKF",
+ "username": "admin",
+ "url": "http://localhost:8080/@admin",
+ "acct": "admin"
+ }
+ ],
+ "tags": [],
+ "emojis": [],
+ "card": null,
+ "poll": null
+}`, string(b))
+}
+
func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownLanguage() {
testStatus := &gtsmodel.Status{}
*testStatus = *suite.testStatuses["admin_account_status_1"]
@@ -459,7 +558,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownLanguage()
"language": null,
"uri": "http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R",
"url": "http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R",
- "replies_count": 0,
+ "replies_count": 1,
"reblogs_count": 0,
"favourites_count": 1,
"favourited": true,
@@ -583,7 +682,8 @@ func (suite *InternalToFrontendTestSuite) TestVideoAttachmentToFrontend() {
"aspect": 1.7821782
}
},
- "description": "A cow adorably licking another cow!"
+ "description": "A cow adorably licking another cow!",
+ "blurhash": null
}`, string(b))
}
diff --git a/internal/typeutils/util.go b/internal/typeutils/util.go
index a99d9e7ae..a19588221 100644
--- a/internal/typeutils/util.go
+++ b/internal/typeutils/util.go
@@ -22,10 +22,17 @@ import (
"errors"
"fmt"
"net/url"
+ "path"
+ "slices"
+ "strconv"
+ "strings"
"github.com/superseriousbusiness/gotosocial/internal/ap"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/regexes"
+ "github.com/superseriousbusiness/gotosocial/internal/text"
)
type statusInteractions struct {
@@ -100,3 +107,80 @@ func getURI(withID ap.WithJSONLDId) (*url.URL, string, error) {
id := idProp.Get()
return id, id.String(), nil
}
+
+// placeholdUnknownAttachments separates any attachments with type `unknown`
+// out of the given slice, and returns an `<aside>` tag containing links to
+// those attachments, as well as the slice of remaining "known" attachments.
+// If there are no unknown-type attachments in the provided slice, an empty
+// string and the original slice will be returned.
+//
+// If an aside is created, it will be run through the sanitizer before being
+// returned, to ensure that malicious links don't cause issues.
+//
+// Example:
+//
+// <aside>
+// <p>Note from your.instance.com: 2 attachments in this status could not be downloaded. Treat the following external links with care:
+// <ul>
+// <li><a href="http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE7ZGJYTSYMXF927GF9353KR.svg" rel="nofollow noreferrer noopener" target="_blank">01HE7ZGJYTSYMXF927GF9353KR.svg</a> [SVG line art of a sloth, public domain]</li>
+// <li><a href="http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE892Y8ZS68TQCNPX7J888P3.mp3" rel="nofollow noreferrer noopener" target="_blank">01HE892Y8ZS68TQCNPX7J888P3.mp3</a> [Jolly salsa song, public domain.]</li>
+// </ul>
+// </p>
+// </aside>
+func placeholdUnknownAttachments(arr []apimodel.Attachment) (string, []apimodel.Attachment) {
+ // Extract unknown-type attachments into a separate
+ // slice, deleting them from arr in the process.
+ var unknowns []apimodel.Attachment
+ arr = slices.DeleteFunc(arr, func(elem apimodel.Attachment) bool {
+ unknown := elem.Type == "unknown"
+ if unknown {
+ // Set aside unknown-type attachment.
+ unknowns = append(unknowns, elem)
+ }
+
+ return unknown
+ })
+
+ unknownsLen := len(unknowns)
+ if unknownsLen == 0 {
+ // No unknown attachments,
+ // nothing to do.
+ return "", arr
+ }
+
+ // Plural / singular.
+ var (
+ attachments string
+ links string
+ )
+
+ if unknownsLen == 1 {
+ attachments = "1 attachment"
+ links = "link"
+ } else {
+ attachments = strconv.Itoa(unknownsLen) + " attachments"
+ links = "links"
+ }
+
+ var aside strings.Builder
+ aside.WriteString(`<aside>`)
+ aside.WriteString(`<p>`)
+ aside.WriteString(`Note from ` + config.GetHost() + `: ` + attachments + ` in this status could not be downloaded. Treat the following external ` + links + ` with care:`)
+ aside.WriteString(`<ul>`)
+ for _, a := range unknowns {
+ var (
+ remoteURL = *a.RemoteURL
+ base = path.Base(remoteURL)
+ entry = fmt.Sprintf(`<a href="%s">%s</a>`, remoteURL, base)
+ )
+ if d := a.Description; d != nil && *d != "" {
+ entry += ` [` + *d + `]`
+ }
+ aside.WriteString(`<li>` + entry + `</li>`)
+ }
+ aside.WriteString(`</ul>`)
+ aside.WriteString(`</p>`)
+ aside.WriteString(`</aside>`)
+
+ return text.SanitizeToHTML(aside.String()), arr
+}