diff options
Diffstat (limited to 'internal/apimodule/media')
-rw-r--r-- | internal/apimodule/media/media.go | 76 | ||||
-rw-r--r-- | internal/apimodule/media/mediacreate.go | 193 | ||||
-rw-r--r-- | internal/apimodule/media/test/mediacreate_test.go | 194 |
3 files changed, 0 insertions, 463 deletions
diff --git a/internal/apimodule/media/media.go b/internal/apimodule/media/media.go deleted file mode 100644 index 8fb9f16ec..000000000 --- a/internal/apimodule/media/media.go +++ /dev/null @@ -1,76 +0,0 @@ -/* - 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 <http://www.gnu.org/licenses/>. -*/ - -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" -) - -// BasePath is the base API path for making media requests -const BasePath = "/api/v1/media" - -// Module implements the ClientAPIModule interface for media -type Module struct { - mediaHandler media.Handler - 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.Handler, mastoConverter mastotypes.Converter, config *config.Config, log *logrus.Logger) apimodule.ClientAPIModule { - return &Module{ - mediaHandler: mediaHandler, - config: config, - db: db, - mastoConverter: mastoConverter, - log: log, - } -} - -// Route satisfies the RESTAPIModule interface -func (m *Module) Route(s router.Router) error { - s.AttachHandler(http.MethodPost, BasePath, m.MediaCreatePOSTHandler) - return nil -} - -// CreateTables populates necessary tables in the given DB -func (m *Module) 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 deleted file mode 100644 index ee713a471..000000000 --- a/internal/apimodule/media/mediacreate.go +++ /dev/null @@ -1,193 +0,0 @@ -/* - 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 <http://www.gnu.org/licenses/>. -*/ - -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" -) - -// MediaCreatePOSTHandler handles requests to create/upload media attachments -func (m *Module) 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 - } - - // open the attachment and extract the bytes from it - 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 - } - 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 - } - - // allow the mediaHandler to work its magic of processing the attachment bytes, and putting them in whatever storage backend we're using - attachment, err := m.mediaHandler.ProcessLocalAttachment(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 - } - - // now we need to add extra fields that the attachment processor doesn't know (from the form) - // TODO: handle this inside mediaHandler.ProcessAttachment (just pass more params to it) - - // first description - attachment.Description = form.Description - - // now parse the focus parameter - // TODO: tidy this up into a separate function and just return an error so all the c.JSON and return calls are obviated - 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 == "" || yStr == "" { - 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, 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, 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 - - // prepare the frontend representation now -- if there are any errors here at least we can bail without - // having already put something in the database and then having to clean it up again (eugh) - 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 - } - - // now we can confidently put the attachment in the database - 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 - } - - // and return its frontend representation - 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 size 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) - } - - if len(form.Description) < config.MinDescriptionChars || len(form.Description) > config.MaxDescriptionChars { - return fmt.Errorf("image description length must be between %d and %d characters (inclusive), but provided image description was %d chars", config.MinDescriptionChars, config.MaxDescriptionChars, len(form.Description)) - } - - // TODO: validate focus here - - return nil -} diff --git a/internal/apimodule/media/test/mediacreate_test.go b/internal/apimodule/media/test/mediacreate_test.go deleted file mode 100644 index 30bbb117a..000000000 --- a/internal/apimodule/media/test/mediacreate_test.go +++ /dev/null @@ -1,194 +0,0 @@ -/* - 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 <http://www.gnu.org/licenses/>. -*/ - -package test - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io/ioutil" - "net/http" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" - mediamodule "github.com/superseriousbusiness/gotosocial/internal/apimodule/media" - "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" - mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/storage" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type MediaCreateTestSuite struct { - // standard suite interfaces - suite.Suite - config *config.Config - db db.DB - log *logrus.Logger - storage storage.Storage - mastoConverter mastotypes.Converter - mediaHandler media.Handler - oauthServer oauth.Server - - // standard suite models - testTokens map[string]*oauth.Token - testClients map[string]*oauth.Client - testApplications map[string]*gtsmodel.Application - testUsers map[string]*gtsmodel.User - testAccounts map[string]*gtsmodel.Account - testAttachments map[string]*gtsmodel.MediaAttachment - - // item being tested - mediaModule *mediamodule.Module -} - -/* - TEST INFRASTRUCTURE -*/ - -func (suite *MediaCreateTestSuite) SetupSuite() { - // setup standard items - suite.config = testrig.NewTestConfig() - suite.db = testrig.NewTestDB() - suite.log = testrig.NewTestLog() - suite.storage = testrig.NewTestStorage() - suite.mastoConverter = testrig.NewTestMastoConverter(suite.db) - suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) - suite.oauthServer = testrig.NewTestOauthServer(suite.db) - - // setup module being tested - suite.mediaModule = mediamodule.New(suite.db, suite.mediaHandler, suite.mastoConverter, suite.config, suite.log).(*mediamodule.Module) -} - -func (suite *MediaCreateTestSuite) TearDownSuite() { - if err := suite.db.Stop(context.Background()); err != nil { - logrus.Panicf("error closing db connection: %s", err) - } -} - -func (suite *MediaCreateTestSuite) SetupTest() { - testrig.StandardDBSetup(suite.db) - testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") - suite.testTokens = testrig.NewTestTokens() - suite.testClients = testrig.NewTestClients() - suite.testApplications = testrig.NewTestApplications() - suite.testUsers = testrig.NewTestUsers() - suite.testAccounts = testrig.NewTestAccounts() - suite.testAttachments = testrig.NewTestAttachments() -} - -func (suite *MediaCreateTestSuite) TearDownTest() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) -} - -/* - ACTUAL TESTS -*/ - -func (suite *MediaCreateTestSuite) TestStatusCreatePOSTImageHandlerSuccessful() { - - // set up the context for the request - t := suite.testTokens["local_account_1"] - oauthToken := oauth.TokenToOauthToken(t) - 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"]) - - // see what's in storage *before* the request - storageKeysBeforeRequest, err := suite.storage.ListKeys() - if err != nil { - panic(err) - } - - // 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, fmt.Sprintf("http://localhost:8080/%s", mediamodule.BasePath), bytes.NewReader(buf.Bytes())) // the endpoint we're hitting - ctx.Request.Header.Set("Content-Type", w.FormDataContentType()) - - // do the actual request - suite.mediaModule.MediaCreatePOSTHandler(ctx) - - // check what's in storage *after* the request - storageKeysAfterRequest, err := suite.storage.ListKeys() - if err != nil { - panic(err) - } - - // check response - suite.EqualValues(http.StatusAccepted, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - fmt.Println(string(b)) - - attachmentReply := &mastomodel.Attachment{} - err = json.Unmarshal(b, attachmentReply) - assert.NoError(suite.T(), err) - - assert.Equal(suite.T(), "this is a test image -- a cool background from somewhere", attachmentReply.Description) - assert.Equal(suite.T(), "image", attachmentReply.Type) - assert.EqualValues(suite.T(), mastomodel.MediaMeta{ - Original: mastomodel.MediaDimensions{ - Width: 1920, - Height: 1080, - Size: "1920x1080", - Aspect: 1.7777778, - }, - Small: mastomodel.MediaDimensions{ - Width: 256, - Height: 144, - Size: "256x144", - Aspect: 1.7777778, - }, - Focus: mastomodel.MediaFocus{ - X: -0.5, - Y: 0.5, - }, - }, attachmentReply.Meta) - assert.Equal(suite.T(), "LjCZnlvyRkRn_NvzRjWF?urqV@f9", attachmentReply.Blurhash) - assert.NotEmpty(suite.T(), attachmentReply.ID) - assert.NotEmpty(suite.T(), attachmentReply.URL) - assert.NotEmpty(suite.T(), attachmentReply.PreviewURL) - assert.Equal(suite.T(), len(storageKeysBeforeRequest)+2, len(storageKeysAfterRequest)) // 2 images should be added to storage: the original and the thumbnail -} - -func TestMediaCreateTestSuite(t *testing.T) { - suite.Run(t, new(MediaCreateTestSuite)) -} |