diff options
author | 2023-11-10 19:29:26 +0100 | |
---|---|---|
committer | 2023-11-10 19:29:26 +0100 | |
commit | ba9d6b467a1f03447789844048d913738c843569 (patch) | |
tree | 5a464ee4a33f26e3284179582ab6d3332d9d5388 /internal/typeutils | |
parent | [chore/bugfix/horror] Allow `expires_in` and poll choices to be parsed from s... (diff) | |
download | gotosocial-ba9d6b467a1f03447789844048d913738c843569.tar.xz |
[feature] Media attachment placeholders (#2331)
* [feature] Use placeholders for unknown media types
* fix read of underreported small files
* switch to reduce nesting
* simplify cleanup
Diffstat (limited to 'internal/typeutils')
-rw-r--r-- | internal/typeutils/internaltoas.go | 2 | ||||
-rw-r--r-- | internal/typeutils/internaltofrontend.go | 105 | ||||
-rw-r--r-- | internal/typeutils/internaltofrontend_test.go | 106 | ||||
-rw-r--r-- | internal/typeutils/util.go | 84 |
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 := >smodel.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 +} |