diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index ec0963190..1da03d662 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -971,6 +971,14 @@ definitions: x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model instance: properties: + account_domain: + description: |- + The domain of accounts on this instance. + This will not necessarily be the same as + simply the Host part of the URI. + example: example.org + type: string + x-go-name: AccountDomain approval_required: description: New account registrations require admin approval. type: boolean @@ -1045,7 +1053,7 @@ definitions: x-go-name: Title uri: description: The URI of the instance. - example: https://example.org + example: https://gts.example.org type: string x-go-name: URI urls: @@ -2000,6 +2008,57 @@ paths: summary: Handles webfinger account lookup requests. tags: - webfinger + /api/{api_version}/media: + post: + consumes: + - multipart/form-data + operationId: mediaCreate + parameters: + - description: Version of the API to use. Must be one of v1 or v2. + in: path + name: api version + required: true + type: string + - description: |- + Image or media description to use as alt-text on the attachment. + This is very useful for users of screenreaders. + May or may not be required, depending on your instance settings. + in: formData + name: description + type: string + - description: |- + Focus of the media file. + If present, it should be in the form of two comma-separated floats between -1 and 1. + For example: `-0.5,0.25`. + in: formData + name: focus + type: string + - description: The media attachment to upload. + in: formData + name: file + required: true + type: file + produces: + - application/json + responses: + "200": + description: The newly-created media attachment. + schema: + $ref: '#/definitions/attachment' + "400": + description: bad request + "401": + description: unauthorized + "422": + description: unprocessable + "500": + description: internal server error + security: + - OAuth2 Bearer: + - write:media + summary: Upload a new media attachment. + tags: + - media /api/v1/accounts: post: consumes: @@ -3255,52 +3314,6 @@ paths: description: internal server error tags: - instance - /api/v1/media: - post: - consumes: - - multipart/form-data - operationId: mediaCreate - parameters: - - description: |- - Image or media description to use as alt-text on the attachment. - This is very useful for users of screenreaders. - May or may not be required, depending on your instance settings. - in: formData - name: description - type: string - - description: |- - Focus of the media file. - If present, it should be in the form of two comma-separated floats between -1 and 1. - For example: `-0.5,0.25`. - in: formData - name: focus - type: string - - description: The media attachment to upload. - in: formData - name: file - required: true - type: file - produces: - - application/json - responses: - "200": - description: The newly-created media attachment. - schema: - $ref: '#/definitions/attachment' - "400": - description: bad request - "401": - description: unauthorized - "422": - description: unprocessable - "500": - description: internal server error - security: - - OAuth2 Bearer: - - write:media - summary: Upload a new media attachment. - tags: - - media /api/v1/media/{id}: get: operationId: mediaGet diff --git a/internal/api/client/media/media.go b/internal/api/client/media/media.go index c9aee64ca..87cc2f091 100644 --- a/internal/api/client/media/media.go +++ b/internal/api/client/media/media.go @@ -26,20 +26,12 @@ "github.com/superseriousbusiness/gotosocial/internal/router" ) -// BasePathV1 is the base API path for making media requests through v1 of the api (for mastodon API compatibility) -const BasePathV1 = "/api/v1/media" - -// BasePathV2 is the base API path for making media requests through v2 of the api (for mastodon API compatibility) -const BasePathV2 = "/api/v2/media" - -// IDKey is the key for media attachment IDs -const IDKey = "id" - -// BasePathWithIDV1 corresponds to a media attachment with the given ID -const BasePathWithIDV1 = BasePathV1 + "/:" + IDKey - -// BasePathWithIDV2 corresponds to a media attachment with the given ID -const BasePathWithIDV2 = BasePathV2 + "/:" + IDKey +const ( + IDKey = "id" // IDKey is the key for media attachment IDs + APIVersionKey = "api_version" // APIVersionKey is the key for which version of the API to use (v1 or v2) + BasePathWithAPIVersion = "/api/:" + APIVersionKey + "/media" // BasePathWithAPIVersion is the base API path for making media requests through v1 or v2 of the api (for mastodon API compatibility) + BasePathWithIDV1 = "/api/v1/media/:" + IDKey // BasePathWithID corresponds to a media attachment with the given ID +) // Module implements the ClientAPIModule interface for media type Module struct { @@ -55,15 +47,8 @@ func New(processor processing.Processor) api.ClientModule { // Route satisfies the RESTAPIModule interface func (m *Module) Route(s router.Router) error { - // v1 handlers - s.AttachHandler(http.MethodPost, BasePathV1, m.MediaCreatePOSTHandler) + s.AttachHandler(http.MethodPost, BasePathWithAPIVersion, m.MediaCreatePOSTHandler) s.AttachHandler(http.MethodGet, BasePathWithIDV1, m.MediaGETHandler) s.AttachHandler(http.MethodPut, BasePathWithIDV1, m.MediaPUTHandler) - - // v2 handlers - s.AttachHandler(http.MethodPost, BasePathV2, m.MediaCreatePOSTHandler) - s.AttachHandler(http.MethodGet, BasePathWithIDV2, m.MediaGETHandler) - s.AttachHandler(http.MethodPut, BasePathWithIDV2, m.MediaPUTHandler) - return nil } diff --git a/internal/api/client/media/mediacreate.go b/internal/api/client/media/mediacreate.go index f51d272b6..5a040b26c 100644 --- a/internal/api/client/media/mediacreate.go +++ b/internal/api/client/media/mediacreate.go @@ -31,7 +31,7 @@ "github.com/superseriousbusiness/gotosocial/internal/oauth" ) -// MediaCreatePOSTHandler swagger:operation POST /api/v1/media mediaCreate +// MediaCreatePOSTHandler swagger:operation POST /api/{api_version}/media mediaCreate // // Upload a new media attachment. // @@ -46,6 +46,11 @@ // - application/json // // parameters: +// - name: api version +// type: string +// in: path +// description: Version of the API to use. Must be one of v1 or v2. +// required: true // - name: description // in: formData // description: |- @@ -95,6 +100,13 @@ func (m *Module) MediaCreatePOSTHandler(c *gin.Context) { return } + apiVersion := c.Param(APIVersionKey) + if apiVersion != "v1" && apiVersion != "v2" { + err := errors.New("api version must be one of v1 or v2") + api.ErrorHandler(c, gtserror.NewErrorNotFound(err, err.Error()), m.processor.InstanceGet) + return + } + form := &model.AttachmentRequest{} if err := c.ShouldBind(&form); err != nil { api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) @@ -112,6 +124,15 @@ func (m *Module) MediaCreatePOSTHandler(c *gin.Context) { return } + if apiVersion == "v2" { + // the mastodon v2 media API specifies that the URL should be null + // and that the client should call /api/v1/media/:id to get the URL + // + // so even though we have the URL already, remove it now to comply + // with the api + apiAttachment.URL = nil + } + c.JSON(http.StatusOK, apiAttachment) } diff --git a/internal/api/client/media/mediacreate_test.go b/internal/api/client/media/mediacreate_test.go index 595edb73d..8651fd982 100644 --- a/internal/api/client/media/mediacreate_test.go +++ b/internal/api/client/media/mediacreate_test.go @@ -30,6 +30,7 @@ "net/http/httptest" "testing" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/suite" mediamodule "github.com/superseriousbusiness/gotosocial/internal/api/client/media" "github.com/superseriousbusiness/gotosocial/internal/api/model" @@ -154,9 +155,15 @@ func (suite *MediaCreateTestSuite) TestMediaCreateSuccessful() { if err != nil { panic(err) } - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", mediamodule.BasePathV1), bytes.NewReader(buf.Bytes())) // the endpoint we're hitting + ctx.Request = httptest.NewRequest(http.MethodPost, "http://localhost:8080/api/v1/media", bytes.NewReader(buf.Bytes())) // the endpoint we're hitting ctx.Request.Header.Set("Content-Type", w.FormDataContentType()) ctx.Request.Header.Set("accept", "application/json") + ctx.Params = gin.Params{ + gin.Param{ + Key: mediamodule.APIVersionKey, + Value: "v1", + }, + } // do the actual request suite.mediaModule.MediaCreatePOSTHandler(ctx) @@ -185,7 +192,7 @@ func (suite *MediaCreateTestSuite) TestMediaCreateSuccessful() { err = json.Unmarshal(b, attachmentReply) suite.NoError(err) - suite.Equal("this is a test image -- a cool background from somewhere", attachmentReply.Description) + suite.Equal("this is a test image -- a cool background from somewhere", *attachmentReply.Description) suite.Equal("image", attachmentReply.Type) suite.EqualValues(model.MediaMeta{ Original: model.MediaDimensions{ @@ -212,6 +219,100 @@ func (suite *MediaCreateTestSuite) TestMediaCreateSuccessful() { suite.Equal(len(storageKeysBeforeRequest)+2, len(storageKeysAfterRequest)) // 2 images should be added to storage: the original and the thumbnail } +func (suite *MediaCreateTestSuite) TestMediaCreateSuccessfulV2() { + // set up the context for the request + t := suite.testTokens["local_account_1"] + oauthToken := oauth.DBTokenToToken(t) + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauthToken) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + + // see what's in storage *before* the request + storageKeysBeforeRequest := []string{} + iter, err := suite.storage.KVStore.Iterator(nil) + if err != nil { + panic(err) + } + for iter.Next() { + storageKeysBeforeRequest = append(storageKeysBeforeRequest, iter.Key()) + } + iter.Release() + + // create the request + buf, w, err := testrig.CreateMultipartFormData("file", "../../../../testrig/media/test-jpeg.jpg", map[string]string{ + "description": "this is a test image -- a cool background from somewhere", + "focus": "-0.5,0.5", + }) + if err != nil { + panic(err) + } + ctx.Request = httptest.NewRequest(http.MethodPost, "http://localhost:8080/api/v2/media", bytes.NewReader(buf.Bytes())) // the endpoint we're hitting + ctx.Request.Header.Set("Content-Type", w.FormDataContentType()) + ctx.Request.Header.Set("accept", "application/json") + ctx.Params = gin.Params{ + gin.Param{ + Key: mediamodule.APIVersionKey, + Value: "v2", + }, + } + + // do the actual request + suite.mediaModule.MediaCreatePOSTHandler(ctx) + + // check what's in storage *after* the request + storageKeysAfterRequest := []string{} + iter, err = suite.storage.KVStore.Iterator(nil) + if err != nil { + panic(err) + } + for iter.Next() { + storageKeysAfterRequest = append(storageKeysAfterRequest, iter.Key()) + } + iter.Release() + + // check response + suite.EqualValues(http.StatusOK, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + fmt.Println(string(b)) + + attachmentReply := &model.Attachment{} + err = json.Unmarshal(b, attachmentReply) + suite.NoError(err) + + suite.Equal("this is a test image -- a cool background from somewhere", *attachmentReply.Description) + suite.Equal("image", attachmentReply.Type) + suite.EqualValues(model.MediaMeta{ + Original: model.MediaDimensions{ + Width: 1920, + Height: 1080, + Size: "1920x1080", + Aspect: 1.7777778, + }, + Small: model.MediaDimensions{ + Width: 512, + Height: 288, + Size: "512x288", + Aspect: 1.7777778, + }, + Focus: model.MediaFocus{ + X: -0.5, + Y: 0.5, + }, + }, attachmentReply.Meta) + suite.Equal("LjBzUo#6RQR._NvzRjWF?urqV@a$", attachmentReply.Blurhash) + suite.NotEmpty(attachmentReply.ID) + suite.Nil(attachmentReply.URL) + suite.NotEmpty(attachmentReply.PreviewURL) + suite.Equal(len(storageKeysBeforeRequest)+2, len(storageKeysAfterRequest)) // 2 images should be added to storage: the original and the thumbnail +} + func (suite *MediaCreateTestSuite) TestMediaCreateLongDescription() { // set up the context for the request t := suite.testTokens["local_account_1"] @@ -238,9 +339,15 @@ func (suite *MediaCreateTestSuite) TestMediaCreateLongDescription() { if err != nil { panic(err) } - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", mediamodule.BasePathV1), bytes.NewReader(buf.Bytes())) // the endpoint we're hitting + ctx.Request = httptest.NewRequest(http.MethodPost, "http://localhost:8080/api/v1/media", bytes.NewReader(buf.Bytes())) // the endpoint we're hitting ctx.Request.Header.Set("Content-Type", w.FormDataContentType()) ctx.Request.Header.Set("accept", "application/json") + ctx.Params = gin.Params{ + gin.Param{ + Key: mediamodule.APIVersionKey, + Value: "v1", + }, + } // do the actual request suite.mediaModule.MediaCreatePOSTHandler(ctx) @@ -278,9 +385,15 @@ func (suite *MediaCreateTestSuite) TestMediaCreateTooShortDescription() { if err != nil { panic(err) } - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", mediamodule.BasePathV1), bytes.NewReader(buf.Bytes())) // the endpoint we're hitting + ctx.Request = httptest.NewRequest(http.MethodPost, "http://localhost:8080/api/v1/media", bytes.NewReader(buf.Bytes())) // the endpoint we're hitting ctx.Request.Header.Set("Content-Type", w.FormDataContentType()) ctx.Request.Header.Set("accept", "application/json") + ctx.Params = gin.Params{ + gin.Param{ + Key: mediamodule.APIVersionKey, + Value: "v1", + }, + } // do the actual request suite.mediaModule.MediaCreatePOSTHandler(ctx) diff --git a/internal/api/client/media/mediaupdate_test.go b/internal/api/client/media/mediaupdate_test.go index 8081a5c15..607f4c31c 100644 --- a/internal/api/client/media/mediaupdate_test.go +++ b/internal/api/client/media/mediaupdate_test.go @@ -145,7 +145,7 @@ func (suite *MediaUpdateTestSuite) TestUpdateImage() { if err != nil { panic(err) } - ctx.Request = httptest.NewRequest(http.MethodPut, fmt.Sprintf("http://localhost:8080/%s/%s", mediamodule.BasePathV1, toUpdate.ID), bytes.NewReader(buf.Bytes())) // the endpoint we're hitting + ctx.Request = httptest.NewRequest(http.MethodPut, fmt.Sprintf("http://localhost:8080/api/v1/media/%s", toUpdate.ID), bytes.NewReader(buf.Bytes())) // the endpoint we're hitting ctx.Request.Header.Set("Content-Type", w.FormDataContentType()) ctx.Request.Header.Set("accept", "application/json") ctx.Params = gin.Params{ @@ -172,7 +172,7 @@ func (suite *MediaUpdateTestSuite) TestUpdateImage() { suite.NoError(err) // the reply should contain the updated fields - suite.Equal("new description!", attachmentReply.Description) + suite.Equal("new description!", *attachmentReply.Description) suite.EqualValues("gif", attachmentReply.Type) suite.EqualValues(model.MediaMeta{ Original: model.MediaDimensions{Width: 800, Height: 450, FrameRate: "", Duration: 0, Bitrate: 0, Size: "800x450", Aspect: 1.7777778}, @@ -181,7 +181,7 @@ func (suite *MediaUpdateTestSuite) TestUpdateImage() { }, attachmentReply.Meta) suite.Equal(toUpdate.Blurhash, attachmentReply.Blurhash) suite.Equal(toUpdate.ID, attachmentReply.ID) - suite.Equal(toUpdate.URL, attachmentReply.URL) + suite.Equal(toUpdate.URL, *attachmentReply.URL) suite.NotEmpty(toUpdate.Thumbnail.URL, attachmentReply.PreviewURL) } @@ -210,7 +210,7 @@ func (suite *MediaUpdateTestSuite) TestUpdateImageShortDescription() { if err != nil { panic(err) } - ctx.Request = httptest.NewRequest(http.MethodPut, fmt.Sprintf("http://localhost:8080/%s/%s", mediamodule.BasePathV1, toUpdate.ID), bytes.NewReader(buf.Bytes())) // the endpoint we're hitting + ctx.Request = httptest.NewRequest(http.MethodPut, fmt.Sprintf("http://localhost:8080/api/v1/media/%s", toUpdate.ID), bytes.NewReader(buf.Bytes())) // the endpoint we're hitting ctx.Request.Header.Set("Content-Type", w.FormDataContentType()) ctx.Request.Header.Set("accept", "application/json") ctx.Params = gin.Params{ diff --git a/internal/api/model/attachment.go b/internal/api/model/attachment.go index 3ab29d2fb..aafa554d8 100644 --- a/internal/api/model/attachment.go +++ b/internal/api/model/attachment.go @@ -68,7 +68,7 @@ type Attachment struct { Type string `json:"type"` // The location of the original full-size attachment. // example: https://example.org/fileserver/some_id/attachments/some_id/original/attachment.jpeg - URL string `json:"url"` + URL *string `json:"url"` // A shorter URL for the attachment. // In our case, we just give the URL again since we don't create smaller URLs. TextURL string `json:"text_url"` @@ -78,16 +78,16 @@ type Attachment struct { // The location of the full-size original attachment on the remote server. // Only defined for instances other than our own. // example: https://some-other-server.org/attachments/original/ahhhhh.jpeg - RemoteURL string `json:"remote_url"` + RemoteURL *string `json:"remote_url"` // The location of a scaled-down preview of the attachment on the remote server. // Only defined for instances other than our own. // example: https://some-other-server.org/attachments/small/ahhhhh.jpeg - PreviewRemoteURL string `json:"preview_remote_url"` + PreviewRemoteURL *string `json:"preview_remote_url"` // Metadata for this attachment. Meta MediaMeta `json:"meta,omitempty"` // Alt text that describes what is in the media attachment. // example: This is a picture of a kitten. - Description string `json:"description"` + Description *string `json:"description"` // A hash computed by the BlurHash algorithm, for generating colorful preview thumbnails when media has not been downloaded yet. // See https://github.com/woltapp/blurhash Blurhash string `json:"blurhash,omitempty"` diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index 1ac49688a..81dfaf9dd 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -233,14 +233,11 @@ func (c *converter) AppToAPIAppPublic(ctx context.Context, a *gtsmodel.Applicati } func (c *converter) AttachmentToAPIAttachment(ctx context.Context, a *gtsmodel.MediaAttachment) (model.Attachment, error) { - return model.Attachment{ - ID: a.ID, - Type: strings.ToLower(string(a.Type)), - URL: a.URL, - TextURL: a.URL, - PreviewURL: a.Thumbnail.URL, - RemoteURL: a.RemoteURL, - PreviewRemoteURL: a.Thumbnail.RemoteURL, + apiAttachment := model.Attachment{ + ID: a.ID, + Type: strings.ToLower(string(a.Type)), + TextURL: a.URL, + PreviewURL: a.Thumbnail.URL, Meta: model.MediaMeta{ Original: model.MediaDimensions{ Width: a.FileMeta.Original.Width, @@ -259,9 +256,31 @@ func (c *converter) AttachmentToAPIAttachment(ctx context.Context, a *gtsmodel.M Y: a.FileMeta.Focus.Y, }, }, - Description: a.Description, - Blurhash: a.Blurhash, - }, nil + Blurhash: a.Blurhash, + } + + // nullable fields + if a.URL != "" { + i := a.URL + apiAttachment.URL = &i + } + + if a.RemoteURL != "" { + i := a.RemoteURL + apiAttachment.RemoteURL = &i + } + + if a.Thumbnail.RemoteURL != "" { + i := a.Thumbnail.RemoteURL + apiAttachment.PreviewRemoteURL = &i + } + + if a.Description != "" { + i := a.Description + apiAttachment.Description = &i + } + + return apiAttachment, nil } func (c *converter) MentionToAPIMention(ctx context.Context, m *gtsmodel.Mention) (model.Mention, error) { diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go index b3a26da6a..2350e64a2 100644 --- a/internal/typeutils/internaltofrontend_test.go +++ b/internal/typeutils/internaltofrontend_test.go @@ -52,7 +52,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() { b, err := json.Marshal(apiStatus) suite.NoError(err) - suite.Equal(`{"id":"01F8MH75CBF9JFX4ZAD54N0W0R","created_at":"2021-10-20T11:36:45.000Z","in_reply_to_id":"","in_reply_to_account_id":"","sensitive":false,"spoiler_text":"","visibility":"public","language":"en","uri":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","url":"http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","replies_count":0,"reblogs_count":0,"favourites_count":1,"favourited":true,"reblogged":false,"muted":false,"bookmarked":false,"pinned":false,"content":"hello world! #welcome ! first post on the instance :rainbow: !","reblog":null,"application":{"name":"superseriousbusiness","website":"https://superserious.business"},"account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"","header_static":"","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[]},"media_attachments":[{"id":"01F8MH6NEM8D7527KZAECTCR76","type":"image","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg","text_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg","preview_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.jpeg","remote_url":"","preview_remote_url":"","meta":{"original":{"width":1200,"height":630,"size":"1200x630","aspect":1.9047619},"small":{"width":256,"height":134,"size":"256x134","aspect":1.9104477},"focus":{"x":0,"y":0}},"description":"Black and white image of some 50's style text saying: Welcome On Board","blurhash":"LNJRdVM{00Rj%Mayt7j[4nWBofRj"}],"mentions":[],"tags":[{"name":"welcome","url":"http://localhost:8080/tags/welcome"}],"emojis":[{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true}],"card":null,"poll":null,"text":""}`, string(b)) + suite.Equal(`{"id":"01F8MH75CBF9JFX4ZAD54N0W0R","created_at":"2021-10-20T11:36:45.000Z","in_reply_to_id":"","in_reply_to_account_id":"","sensitive":false,"spoiler_text":"","visibility":"public","language":"en","uri":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","url":"http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","replies_count":0,"reblogs_count":0,"favourites_count":1,"favourited":true,"reblogged":false,"muted":false,"bookmarked":false,"pinned":false,"content":"hello world! #welcome ! first post on the instance :rainbow: !","reblog":null,"application":{"name":"superseriousbusiness","website":"https://superserious.business"},"account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"","header_static":"","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[]},"media_attachments":[{"id":"01F8MH6NEM8D7527KZAECTCR76","type":"image","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg","text_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg","preview_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.jpeg","remote_url":null,"preview_remote_url":null,"meta":{"original":{"width":1200,"height":630,"size":"1200x630","aspect":1.9047619},"small":{"width":256,"height":134,"size":"256x134","aspect":1.9104477},"focus":{"x":0,"y":0}},"description":"Black and white image of some 50's style text saying: Welcome On Board","blurhash":"LNJRdVM{00Rj%Mayt7j[4nWBofRj"}],"mentions":[],"tags":[{"name":"welcome","url":"http://localhost:8080/tags/welcome"}],"emojis":[{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true}],"card":null,"poll":null,"text":""}`, string(b)) } func (suite *InternalToFrontendTestSuite) TestInstanceToFrontend() {