encode gifs properly

This commit is contained in:
tsmethurst 2021-04-12 16:48:31 +02:00
commit 2e7ac10d00
15 changed files with 304 additions and 66 deletions

View file

@ -149,7 +149,7 @@ func (m *statusModule) statusCreatePOSTHandler(c *gin.Context) {
return
}
}
newStatus.Mentions = menchies
newStatus.GTSMentions = menchies
// convert tags to *gtsmodel.Tag
tags, err := m.db.TagStringsToTags(util.DeriveHashtags(form.Status), authed.Account.ID, thisStatusID)
@ -158,7 +158,7 @@ func (m *statusModule) statusCreatePOSTHandler(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "error generating hashtags from status"})
return
}
newStatus.Tags = tags
newStatus.GTSTags = tags
// convert emojis to *gtsmodel.Emoji
emojis, err := m.db.EmojiStringsToEmojis(util.DeriveEmojis(form.Status), authed.Account.ID, thisStatusID)
@ -167,7 +167,7 @@ func (m *statusModule) statusCreatePOSTHandler(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "error generating emojis from status"})
return
}
newStatus.Emojis = emojis
newStatus.GTSEmojis = emojis
/*
FROM THIS POINT ONWARDS WE ARE HAPPY WITH THE STATUS -- it is valid and we will try to create it
@ -180,7 +180,7 @@ func (m *statusModule) statusCreatePOSTHandler(c *gin.Context) {
}
// change the status ID of the media attachments to the new status
for _, a := range newStatus.Attachments {
for _, a := range newStatus.GTSMediaAttachments {
a.StatusID = newStatus.ID
a.UpdatedAt = time.Now()
if err := m.db.UpdateByID(a.ID, a); err != nil {
@ -207,7 +207,7 @@ func (m *statusModule) statusCreatePOSTHandler(c *gin.Context) {
}
mastoAttachments := []mastotypes.Attachment{}
for _, a := range newStatus.Attachments {
for _, a := range newStatus.GTSMediaAttachments {
ma, err := m.mastoConverter.AttachmentToMasto(a)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@ -217,7 +217,7 @@ func (m *statusModule) statusCreatePOSTHandler(c *gin.Context) {
}
mastoMentions := []mastotypes.Mention{}
for _, gtsm := range newStatus.Mentions {
for _, gtsm := range newStatus.GTSMentions {
mm, err := m.mastoConverter.MentionToMasto(gtsm)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@ -433,7 +433,8 @@ func (m *statusModule) parseMediaIDs(form *advancedStatusCreateForm, thisAccount
return nil
}
attachments := []*gtsmodel.MediaAttachment{}
GTSMediaAttachments := []*gtsmodel.MediaAttachment{}
Attachments := []string{}
for _, mediaID := range form.MediaIDs {
// check these attachments exist
a := &gtsmodel.MediaAttachment{}
@ -448,9 +449,11 @@ func (m *statusModule) parseMediaIDs(form *advancedStatusCreateForm, thisAccount
if a.StatusID != "" || a.ScheduledStatusID != "" {
return fmt.Errorf("media with id %s is already attached to a status", mediaID)
}
attachments = append(attachments, a)
GTSMediaAttachments = append(GTSMediaAttachments, a)
Attachments = append(Attachments, a.ID)
}
status.Attachments = attachments
status.GTSMediaAttachments = GTSMediaAttachments
status.Attachments = Attachments
return nil
}

View file

@ -116,7 +116,8 @@ func (suite *StatusCreateTestSuite) TearDownTest() {
TESTING: StatusCreatePOSTHandler
*/
func (suite *StatusCreateTestSuite) TestStatusCreatePOSTHandlerSuccessful() {
// Post a new status with some custom visibility settings
func (suite *StatusCreateTestSuite) TestPostNewStatus() {
t := suite.testTokens["local_account_1"]
oauthToken := oauth.PGTokenToOauthToken(t)
@ -160,7 +161,8 @@ func (suite *StatusCreateTestSuite) TestStatusCreatePOSTHandlerSuccessful() {
assert.Equal(suite.T(), mastomodel.VisibilityPrivate, statusReply.Visibility)
}
func (suite *StatusCreateTestSuite) TestStatusCreatePOSTHandlerReplyToFail() {
// Try to reply to a status that doesn't exist
func (suite *StatusCreateTestSuite) TestReplyToNonexistentStatus() {
t := suite.testTokens["local_account_1"]
oauthToken := oauth.PGTokenToOauthToken(t)
@ -190,7 +192,8 @@ func (suite *StatusCreateTestSuite) TestStatusCreatePOSTHandlerReplyToFail() {
assert.Equal(suite.T(), `{"error":"status with id 3759e7ef-8ee1-4c0c-86f6-8b70b9ad3d50 not replyable because it doesn't exist"}`, string(b))
}
func (suite *StatusCreateTestSuite) TestStatusCreatePOSTHandlerReplyToLocalSuccess() {
// Post a reply to the status of a local user that allows replies.
func (suite *StatusCreateTestSuite) TestReplyToLocalStatus() {
t := suite.testTokens["local_account_1"]
oauthToken := oauth.PGTokenToOauthToken(t)
@ -229,6 +232,63 @@ func (suite *StatusCreateTestSuite) TestStatusCreatePOSTHandlerReplyToLocalSucce
assert.Len(suite.T(), statusReply.Mentions, 1)
}
// Take a media file which is currently not associated with a status, and attach it to a new status.
func (suite *StatusCreateTestSuite) TestAttachNewMediaSuccess() {
t := suite.testTokens["local_account_1"]
oauthToken := oauth.PGTokenToOauthToken(t)
// setup
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
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"])
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", basePath), nil) // the endpoint we're hitting
ctx.Request.Form = url.Values{
"status": {"here's an image attachment"},
"media_ids": {"7a3b9f77-ab30-461e-bdd8-e64bd1db3008"},
}
suite.statusModule.statusCreatePOSTHandler(ctx)
// check response
suite.EqualValues(http.StatusOK, recorder.Code)
result := recorder.Result()
defer result.Body.Close()
b, err := ioutil.ReadAll(result.Body)
assert.NoError(suite.T(), err)
fmt.Println(string(b))
statusReply := &mastomodel.Status{}
err = json.Unmarshal(b, statusReply)
assert.NoError(suite.T(), err)
assert.Equal(suite.T(), "", statusReply.SpoilerText)
assert.Equal(suite.T(), "here's an image attachment", statusReply.Content)
assert.False(suite.T(), statusReply.Sensitive)
assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility)
// there should be one media attachment
assert.Len(suite.T(), statusReply.MediaAttachments, 1)
// get the updated media attachment from the database
gtsAttachment := &gtsmodel.MediaAttachment{}
err = suite.db.GetByID(statusReply.MediaAttachments[0].ID, gtsAttachment)
assert.NoError(suite.T(), err)
// convert it to a masto attachment
gtsAttachmentAsMasto, err := suite.mastoConverter.AttachmentToMasto(gtsAttachment)
assert.NoError(suite.T(), err)
// compare it with what we have now
assert.EqualValues(suite.T(), statusReply.MediaAttachments[0], gtsAttachmentAsMasto)
// the status id of the attachment should now be set to the id of the status we just created
assert.Equal(suite.T(), statusReply.ID, gtsAttachment.StatusID)
}
func TestStatusCreateTestSuite(t *testing.T) {
suite.Run(t, new(StatusCreateTestSuite))
}

View file

@ -117,8 +117,8 @@ func GetDefaults() Defaults {
AccountsRequireApproval: true,
AccountsReasonRequired: true,
MediaMaxImageSize: 1048576, //1mb
MediaMaxVideoSize: 5242880, //5mb
MediaMaxImageSize: 2097152, //2mb
MediaMaxVideoSize: 10485760, //10mb
MediaMinDescriptionChars: 0,
MediaMaxDescriptionChars: 500,

View file

@ -110,7 +110,7 @@ const (
// FileTypeImage is for jpegs and pngs
FileTypeImage FileType = "image"
// FileTypeGif is for native gifs and soundless videos that have been converted to gifs
FileTypeGif FileType = "gifv"
FileTypeGif FileType = "gif"
// FileTypeAudio is for audio-only files (no video)
FileTypeAudio FileType = "audio"
// FileTypeVideo is for files with audio + visual

View file

@ -30,6 +30,8 @@ type Status struct {
URL string `pg:",unique"`
// the html-formatted content of this status
Content string
// Database IDs of any media attachments associated with this status
Attachments []string
// when was this status created?
CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
// when was this status updated?
@ -62,21 +64,21 @@ type Status struct {
NON-DATABASE FIELDS
These are for convenience while passing the status around internally,
but these fields should never be put in the db.
but these fields should *never* be put in the db.
*/
// Mentions created in this status
Mentions []*Mention `pg:"-"`
GTSMentions []*Mention `pg:"-"`
// Hashtags used in this status
Tags []*Tag `pg:"-"`
GTSTags []*Tag `pg:"-"`
// Emojis used in this status
Emojis []*Emoji `pg:"-"`
// Attachments used in this status
Attachments []*MediaAttachment `pg:"-"`
GTSEmojis []*Emoji `pg:"-"`
// MediaAttachments used in this status
GTSMediaAttachments []*MediaAttachment `pg:"-"`
// Status being replied to
ReplyToStatus *Status `pg:"-"`
GTSReplyToStatus *Status `pg:"-"`
// Account being replied to
ReplyToAccount *Account `pg:"-"`
GTSReplyToAccount *Account `pg:"-"`
}
// Visibility represents the visibility granularity of a status.

View file

@ -50,7 +50,7 @@ type MediaHandler interface {
// ProcessAttachment takes a new attachment and the requesting account, checks it out, removes exif data from it,
// puts it in whatever storage backend we're using, sets the relevant fields in the database for the new media,
// and then returns information to the caller about the attachment.
ProcessAttachment(img []byte, accountID string) (*gtsmodel.MediaAttachment, error)
ProcessAttachment(attachment []byte, accountID string) (*gtsmodel.MediaAttachment, error)
}
type mediaHandler struct {
@ -73,15 +73,15 @@ func New(config *config.Config, database db.DB, storage storage.Storage, log *lo
INTERFACE FUNCTIONS
*/
func (mh *mediaHandler) SetHeaderOrAvatarForAccountID(img []byte, accountID string, headerOrAvi string) (*gtsmodel.MediaAttachment, error) {
func (mh *mediaHandler) SetHeaderOrAvatarForAccountID(attachment []byte, accountID string, headerOrAvi string) (*gtsmodel.MediaAttachment, error) {
l := mh.log.WithField("func", "SetHeaderForAccountID")
if headerOrAvi != MediaHeader && headerOrAvi != MediaAvatar {
return nil, errors.New("header or avatar not selected")
}
// make sure we have an image we can handle
contentType, err := parseContentType(img)
// make sure we have a type we can handle
contentType, err := parseContentType(attachment)
if err != nil {
return nil, err
}
@ -89,13 +89,13 @@ func (mh *mediaHandler) SetHeaderOrAvatarForAccountID(img []byte, accountID stri
return nil, fmt.Errorf("%s is not an accepted image type", contentType)
}
if len(img) == 0 {
if len(attachment) == 0 {
return nil, fmt.Errorf("passed reader was of size 0")
}
l.Tracef("read %d bytes of file", len(img))
l.Tracef("read %d bytes of file", len(attachment))
// process it
ma, err := mh.processHeaderOrAvi(img, contentType, headerOrAvi, accountID)
ma, err := mh.processHeaderOrAvi(attachment, contentType, headerOrAvi, accountID)
if err != nil {
return nil, fmt.Errorf("error processing %s: %s", headerOrAvi, err)
}
@ -108,8 +108,8 @@ func (mh *mediaHandler) SetHeaderOrAvatarForAccountID(img []byte, accountID stri
return ma, nil
}
func (mh *mediaHandler) ProcessAttachment(data []byte, accountID string) (*gtsmodel.MediaAttachment, error) {
contentType, err := parseContentType(data)
func (mh *mediaHandler) ProcessAttachment(attachment []byte, accountID string) (*gtsmodel.MediaAttachment, error) {
contentType, err := parseContentType(attachment)
if err != nil {
return nil, err
}
@ -119,24 +119,24 @@ func (mh *mediaHandler) ProcessAttachment(data []byte, accountID string) (*gtsmo
if !supportedVideoType(contentType) {
return nil, fmt.Errorf("video type %s not supported", contentType)
}
if len(data) == 0 {
if len(attachment) == 0 {
return nil, errors.New("video was of size 0")
}
if len(data) > mh.config.MediaConfig.MaxVideoSize {
return nil, fmt.Errorf("video size %d bytes exceeded max video size of %d bytes", len(data), mh.config.MediaConfig.MaxVideoSize)
if len(attachment) > mh.config.MediaConfig.MaxVideoSize {
return nil, fmt.Errorf("video size %d bytes exceeded max video size of %d bytes", len(attachment), mh.config.MediaConfig.MaxVideoSize)
}
return mh.processVideo(data, accountID, contentType)
return mh.processVideo(attachment, accountID, contentType)
case "image":
if !supportedImageType(contentType) {
return nil, fmt.Errorf("image type %s not supported", contentType)
}
if len(data) == 0 {
if len(attachment) == 0 {
return nil, errors.New("image was of size 0")
}
if len(data) > mh.config.MediaConfig.MaxImageSize {
return nil, fmt.Errorf("image size %d bytes exceeded max image size of %d bytes", len(data), mh.config.MediaConfig.MaxImageSize)
if len(attachment) > mh.config.MediaConfig.MaxImageSize {
return nil, fmt.Errorf("image size %d bytes exceeded max image size of %d bytes", len(attachment), mh.config.MediaConfig.MaxImageSize)
}
return mh.processImage(data, accountID, contentType)
return mh.processImage(attachment, accountID, contentType)
default:
break
}
@ -154,28 +154,29 @@ func (mh *mediaHandler) processVideo(data []byte, accountID string, contentType
func (mh *mediaHandler) processImage(data []byte, accountID string, contentType string) (*gtsmodel.MediaAttachment, error) {
var clean []byte
var err error
var original *imageAndMeta
var small *imageAndMeta
switch contentType {
case "image/jpeg":
case "image/jpeg", "image/png":
if clean, err = purgeExif(data); err != nil {
return nil, fmt.Errorf("error cleaning exif data: %s", err)
}
case "image/png":
if clean, err = purgeExif(data); err != nil {
return nil, fmt.Errorf("error cleaning exif data: %s", err)
original, err = deriveImage(clean, contentType)
if err != nil {
return nil, fmt.Errorf("error parsing image: %s", err)
}
case "image/gif":
clean = data
original, err = deriveGif(clean, contentType)
if err != nil {
return nil, fmt.Errorf("error parsing gif: %s", err)
}
default:
return nil, errors.New("media type unrecognized")
}
original, err := deriveImage(clean, contentType)
if err != nil {
return nil, fmt.Errorf("error parsing image: %s", err)
}
small, err := deriveThumbnail(clean, contentType)
small, err = deriveThumbnail(clean, contentType)
if err != nil {
return nil, fmt.Errorf("error deriving thumbnail: %s", err)
}
@ -186,7 +187,7 @@ func (mh *mediaHandler) processImage(data []byte, accountID string, contentType
URLbase := fmt.Sprintf("%s://%s%s", mh.config.StorageConfig.ServeProtocol, mh.config.StorageConfig.ServeHost, mh.config.StorageConfig.ServeBasePath)
originalURL := fmt.Sprintf("%s/%s/attachment/original/%s.%s", URLbase, accountID, newMediaID, extension)
smallURL := fmt.Sprintf("%s/%s/attachment/small/%s.%s", URLbase, accountID, newMediaID, extension)
smallURL := fmt.Sprintf("%s/%s/attachment/small/%s.jpeg", URLbase, accountID, newMediaID) // all thumbnails/smalls are encoded as jpeg
// we store the original...
originalPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, MediaAttachment, MediaOriginal, newMediaID, extension)
@ -195,7 +196,7 @@ func (mh *mediaHandler) processImage(data []byte, accountID string, contentType
}
// and a thumbnail...
smallPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, MediaAttachment, MediaSmall, newMediaID, extension)
smallPath := fmt.Sprintf("%s/%s/%s/%s/%s.jpeg", mh.config.StorageConfig.BasePath, accountID, MediaAttachment, MediaSmall, newMediaID) // all thumbnails/smalls are encoded as jpeg
if err := mh.storage.StoreFileAt(smallPath, small.image); err != nil {
return nil, fmt.Errorf("storage error: %s", err)
}
@ -235,7 +236,7 @@ func (mh *mediaHandler) processImage(data []byte, accountID string, contentType
},
Thumbnail: gtsmodel.Thumbnail{
Path: smallPath,
ContentType: contentType,
ContentType: "image/jpeg", // all thumbnails/smalls are encoded as jpeg
FileSize: len(small.image),
UpdatedAt: time.Now(),
URL: smallURL,

View file

@ -103,6 +103,45 @@ func purgeExif(b []byte) ([]byte, error) {
return clean, nil
}
func deriveGif(b []byte, extension string) (*imageAndMeta, error) {
var g *gif.GIF
var err error
switch extension {
case "image/gif":
g, err = gif.DecodeAll(bytes.NewReader(b))
if err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("extension %s not recognised", extension)
}
// use the first frame to get the static characteristics
width := g.Config.Width
height := g.Config.Height
size := width * height
aspect := float64(width) / float64(height)
bh, err := blurhash.Encode(4, 3, g.Image[0])
if err != nil || bh == "" {
return nil, err
}
out := &bytes.Buffer{}
if err := gif.EncodeAll(out, g); err != nil {
return nil, err
}
return &imageAndMeta{
image: out.Bytes(),
width: width,
height: height,
size: size,
aspect: aspect,
blurhash: bh,
}, nil
}
func deriveImage(b []byte, extension string) (*imageAndMeta, error) {
var i image.Image
var err error
@ -118,11 +157,6 @@ func deriveImage(b []byte, extension string) (*imageAndMeta, error) {
if err != nil {
return nil, err
}
case "image/gif":
i, err = gif.Decode(bytes.NewReader(b))
if err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("extension %s not recognised", extension)
}
@ -131,15 +165,17 @@ func deriveImage(b []byte, extension string) (*imageAndMeta, error) {
height := i.Bounds().Size().Y
size := width * height
aspect := float64(width) / float64(height)
bh, err := blurhash.Encode(4, 3, i)
if err != nil {
return nil, fmt.Errorf("error generating blurhash: %s", err)
return nil, err
}
out := &bytes.Buffer{}
if err := jpeg.Encode(out, i, nil); err != nil {
return nil, err
}
return &imageAndMeta{
image: out.Bytes(),
width: width,