diff --git a/internal/apimodule/media/media.go b/internal/apimodule/media/media.go new file mode 100644 index 000000000..6b2264c68 --- /dev/null +++ b/internal/apimodule/media/media.go @@ -0,0 +1,73 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package media + +import ( + "fmt" + "net/http" + + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/apimodule" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/mastotypes" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/router" +) + +const mediaPath = "/api/v1/media" + +type mediaModule struct { + mediaHandler media.MediaHandler + config *config.Config + db db.DB + mastoConverter mastotypes.Converter + log *logrus.Logger +} + +// New returns a new auth module +func New(db db.DB, mediaHandler media.MediaHandler, mastoConverter mastotypes.Converter, config *config.Config, log *logrus.Logger) apimodule.ClientAPIModule { + return &mediaModule{ + mediaHandler: mediaHandler, + config: config, + db: db, + mastoConverter: mastoConverter, + log: log, + } +} + +// Route satisfies the RESTAPIModule interface +func (m *mediaModule) Route(s router.Router) error { + s.AttachHandler(http.MethodPost, mediaPath, m.mediaCreatePOSTHandler) + return nil +} + +func (m *mediaModule) CreateTables(db db.DB) error { + models := []interface{}{ + >smodel.MediaAttachment{}, + } + + for _, m := range models { + if err := db.CreateTable(m); err != nil { + return fmt.Errorf("error creating table: %s", err) + } + } + return nil +} diff --git a/internal/apimodule/media/mediacreate.go b/internal/apimodule/media/mediacreate.go new file mode 100644 index 000000000..d262a6e1a --- /dev/null +++ b/internal/apimodule/media/mediacreate.go @@ -0,0 +1,175 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package media + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/config" + mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +func (m *mediaModule) mediaCreatePOSTHandler(c *gin.Context) { + l := m.log.WithField("func", "statusCreatePOSTHandler") + authed, err := oauth.MustAuth(c, true, true, true, true) // posting new media is serious business so we want *everything* + if err != nil { + l.Debugf("couldn't auth: %s", err) + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + + // First check this user/account is permitted to create media + // There's no point continuing otherwise. + if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() { + l.Debugf("couldn't auth: %s", err) + c.JSON(http.StatusForbidden, gin.H{"error": "account is disabled, not yet approved, or suspended"}) + return + } + + // extract the media create form from the request context + l.Tracef("parsing request form: %s", c.Request.Form) + form := &mastotypes.AttachmentRequest{} + if err := c.ShouldBind(form); err != nil || form == nil { + l.Debugf("could not parse form from request: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"}) + return + } + + // Give the fields on the request form a first pass to make sure the request is superficially valid. + l.Tracef("validating form %+v", form) + if err := validateCreateMedia(form, m.config.MediaConfig); err != nil { + l.Debugf("error validating form: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + f, err := form.File.Open() + if err != nil { + l.Debugf("error opening attachment: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not open provided attachment: %s", err)}) + return + } + + // extract the bytes + buf := new(bytes.Buffer) + size, err := io.Copy(buf, f) + if err != nil { + l.Debugf("error reading attachment: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not read provided attachment: %s", err)}) + return + } + if size == 0 { + l.Debug("could not read provided attachment: size 0 bytes") + c.JSON(http.StatusBadRequest, gin.H{"error": "could not read provided attachment: size 0 bytes"}) + return + } + + attachment, err := m.mediaHandler.ProcessAttachment(buf.Bytes(), authed.Account.ID) + if err != nil { + l.Debugf("error reading attachment: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not process attachment: %s", err)}) + return + } + + attachment.Description = form.Description + var focusx, focusy float32 + if form.Focus != "" { + spl := strings.Split(form.Focus, ",") + if len(spl) != 2 { + l.Debugf("improperly formatted focus %s", form.Focus) + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) + return + } + xStr := spl[0] + yStr := spl[1] + if xStr == "" || xStr == "" { + l.Debugf("improperly formatted focus %s", form.Focus) + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) + return + } + fx, err := strconv.ParseFloat(xStr[:4], 32) + if err != nil { + l.Debugf("improperly formatted focus %s: %s", form.Focus, err) + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) + return + } + if fx > 1 || fx < -1 { + l.Debugf("improperly formatted focus %s", form.Focus) + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) + return + } + focusx = float32(fx) + fy, err := strconv.ParseFloat(yStr[:4], 32) + if err != nil { + l.Debugf("improperly formatted focus %s: %s", form.Focus, err) + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) + return + } + if fy > 1 || fy < -1 { + l.Debugf("improperly formatted focus %s", form.Focus) + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) + return + } + focusy = float32(fy) + } + + attachment.FileMeta.Focus.X = focusx + attachment.FileMeta.Focus.Y = focusy + + mastoAttachment, err := m.mastoConverter.AttachmentToMasto(attachment) + if err != nil { + l.Debugf("error parsing media attachment to frontend type: %s", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("error parsing media attachment to frontend type: %s", err)}) + return + } + + if err := m.db.Put(attachment); err != nil { + l.Debugf("error storing media attachment in db: %s", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("error storing media attachment in db: %s", err)}) + return + } + + c.JSON(http.StatusAccepted, mastoAttachment) +} + +func validateCreateMedia(form *mastotypes.AttachmentRequest, config *config.MediaConfig) error { + // check there actually is a file attached and it's not size 0 + if form.File == nil || form.File.Size == 0 { + return errors.New("no attachment given") + } + + // a very superficial check to see if no limits are exceeded + // we still don't actually know which media types we're dealing with but the other handlers will go into more detail there + maxSize := config.MaxVideoSize + if config.MaxImageSize > maxSize { + maxSize = config.MaxImageSize + } + if form.File.Size > int64(maxSize) { + return fmt.Errorf("file size limit exceeded: limit is %d bytes but attachment was %d bytes", maxSize, form.File.Size) + } + return nil +} diff --git a/internal/db/gtsmodel/mediaattachment.go b/internal/db/gtsmodel/mediaattachment.go index a906f8350..c0ad4f135 100644 --- a/internal/db/gtsmodel/mediaattachment.go +++ b/internal/db/gtsmodel/mediaattachment.go @@ -123,6 +123,7 @@ const ( type FileMeta struct { Original Original Small Small + Focus Focus } // Small can be used for a thumbnail of any media type @@ -140,3 +141,8 @@ type Original struct { Size int Aspect float64 } + +type Focus struct { + X float32 + Y float32 +} diff --git a/internal/mastotypes/converter.go b/internal/mastotypes/converter.go index b227f0c22..da153a72d 100644 --- a/internal/mastotypes/converter.go +++ b/internal/mastotypes/converter.go @@ -255,6 +255,10 @@ func (c *converter) AttachmentToMasto(a *gtsmodel.MediaAttachment) (mastotypes.A Size: fmt.Sprintf("%dx%d", a.FileMeta.Small.Width, a.FileMeta.Small.Height), Aspect: float32(a.FileMeta.Small.Aspect), }, + Focus: mastotypes.MediaFocus{ + X: a.FileMeta.Focus.X, + Y: a.FileMeta.Focus.Y, + }, }, Description: a.Description, Blurhash: a.Blurhash, diff --git a/internal/media/media.go b/internal/media/media.go index 104342e6e..0e60c2c09 100644 --- a/internal/media/media.go +++ b/internal/media/media.go @@ -38,6 +38,11 @@ type MediaHandler interface { // puts it in whatever storage backend we're using, sets the relevant fields in the database for the new image, // and then returns information to the caller about the new header. SetHeaderOrAvatarForAccountID(img []byte, accountID string, headerOrAvi string) (*gtsmodel.MediaAttachment, error) + + // 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) } type mediaHandler struct { @@ -103,10 +108,147 @@ 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) + if err != nil { + return nil, err + } + mainType := strings.Split(contentType, "/")[0] + switch mainType { + case "video": + if !supportedVideoType(contentType) { + return nil, fmt.Errorf("video type %s not supported", contentType) + } + if len(data) == 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) + } + return mh.processVideo(data, accountID, contentType) + case "image": + if !supportedImageType(contentType) { + return nil, fmt.Errorf("image type %s not supported", contentType) + } + if len(data) == 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) + } + return mh.processImage(data, accountID, contentType) + default: + break + } + return nil, fmt.Errorf("content type %s not (yet) supported", contentType) +} + /* HELPER FUNCTIONS */ +func (mh *mediaHandler) processVideo(data []byte, accountID string, contentType string) (*gtsmodel.MediaAttachment, error) { + return nil, nil +} + +func (mh *mediaHandler) processImage(data []byte, accountID string, contentType string) (*gtsmodel.MediaAttachment, error) { + var clean []byte + var err error + + switch contentType { + case "image/jpeg": + 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) + } + case "image/gif": + clean = data + 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) + if err != nil { + return nil, fmt.Errorf("error deriving thumbnail: %s", err) + } + + // now put it in storage, take a new uuid for the name of the file so we don't store any unnecessary info about it + extension := strings.Split(contentType, "/")[1] + newMediaID := uuid.NewString() + + 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) + + // we store the original... + originalPath := fmt.Sprintf("%s/%s/attachment/original/%s.%s", mh.config.StorageConfig.BasePath, accountID, newMediaID, extension) + if err := mh.storage.StoreFileAt(originalPath, original.image); err != nil { + return nil, fmt.Errorf("storage error: %s", err) + } + + // and a thumbnail... + smallPath := fmt.Sprintf("%s/%s/attachment/small/%s.%s", mh.config.StorageConfig.BasePath, accountID, newMediaID, extension) + if err := mh.storage.StoreFileAt(smallPath, small.image); err != nil { + return nil, fmt.Errorf("storage error: %s", err) + } + + ma := >smodel.MediaAttachment{ + ID: newMediaID, + StatusID: "", + URL: originalURL, + RemoteURL: "", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Type: gtsmodel.FileTypeImage, + FileMeta: gtsmodel.FileMeta{ + Original: gtsmodel.Original{ + Width: original.width, + Height: original.height, + Size: original.size, + Aspect: original.aspect, + }, + Small: gtsmodel.Small{ + Width: small.width, + Height: small.height, + Size: small.size, + Aspect: small.aspect, + }, + }, + AccountID: accountID, + Description: "", + ScheduledStatusID: "", + Blurhash: original.blurhash, + Processing: 2, + File: gtsmodel.File{ + Path: originalPath, + ContentType: contentType, + FileSize: len(original.image), + UpdatedAt: time.Now(), + }, + Thumbnail: gtsmodel.Thumbnail{ + Path: smallPath, + ContentType: contentType, + FileSize: len(small.image), + UpdatedAt: time.Now(), + URL: smallURL, + RemoteURL: "", + }, + Avatar: false, + Header: false, + } + + return ma, nil + +} + func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string, headerOrAvi string, accountID string) (*gtsmodel.MediaAttachment, error) { var isHeader bool var isAvatar bool diff --git a/internal/media/util.go b/internal/media/util.go index 9ffb79a46..5b5df7f00 100644 --- a/internal/media/util.go +++ b/internal/media/util.go @@ -70,6 +70,22 @@ func supportedImageType(mimeType string) bool { return false } +// supportedVideoType checks mime type of a video against a slice of accepted types, +// and returns True if the mime type is accepted. +func supportedVideoType(mimeType string) bool { + acceptedVideoTypes := []string{ + "video/mp4", + "video/mpeg", + "video/webm", + } + for _, accepted := range acceptedVideoTypes { + if mimeType == accepted { + return true + } + } + return false +} + // purgeExif is a little wrapper for the action of removing exif data from an image. // Only pass pngs or jpegs to this function. func purgeExif(b []byte) ([]byte, error) { @@ -134,7 +150,7 @@ func deriveImage(b []byte, extension string) (*imageAndMeta, error) { }, nil } -// deriveThumbnailFromImage returns a byte slice and metadata for a 256-pixel-width thumbnail +// deriveThumbnail returns a byte slice and metadata for a 256-pixel-width thumbnail // of a given jpeg, png, or gif, or an error if something goes wrong. // // Note that the aspect ratio of the image will be retained, diff --git a/testrig/db.go b/testrig/db.go index 176e8dada..873ea5fed 100644 --- a/testrig/db.go +++ b/testrig/db.go @@ -61,6 +61,12 @@ func StandardDBSetup(db db.DB) error { } } + for _, v := range TestAttachments() { + if err := db.Put(v); err != nil { + return err + } + } + for _, v := range TestStatuses() { if err := db.Put(v); err != nil { return err diff --git a/testrig/models.go b/testrig/models.go index f28ed74ed..7cb24cbab 100644 --- a/testrig/models.go +++ b/testrig/models.go @@ -440,6 +440,14 @@ func TestAccounts() map[string]*gtsmodel.Account { return accounts } +func TestAttachments() map[string]*gtsmodel.MediaAttachment { + return map[string]*gtsmodel.MediaAttachment{ + "admin_account_status_1": { + + }, + } +} + func TestStatuses() map[string]*gtsmodel.Status { return map[string]*gtsmodel.Status{ "admin_account_status_1": {