Api/v1/statuses (#11)

This PR adds:
Statuses

    New status creation.
    View existing status
    Delete a status
    Fave a status
    Unfave a status
    See who's faved a status

Media

    Upload media attachment and store/retrieve it
    Upload custom emoji and store/retrieve it

Fileserver

    Serve files from storage

Testing

    Test models, testrig -- run a GTS test instance and play around with it.
This commit is contained in:
Tobi Smethurst
2021-04-19 19:42:19 +02:00
committed by GitHub
parent 71a49e2b43
commit 32c5fd987a
150 changed files with 9023 additions and 788 deletions

View File

@ -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 <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"
)
const BasePath = "/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, BasePath, m.MediaCreatePOSTHandler)
return nil
}
func (m *MediaModule) CreateTables(db db.DB) error {
models := []interface{}{
&gtsmodel.MediaAttachment{},
}
for _, m := range models {
if err := db.CreateTable(m); err != nil {
return fmt.Errorf("error creating table: %s", err)
}
}
return nil
}

View File

@ -0,0 +1,192 @@
/*
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"
)
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
}
// 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
}

View File

@ -0,0 +1,194 @@
/*
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.MediaHandler
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.MediaModule
}
/*
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.MediaModule)
}
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.PGTokenToOauthToken(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))
}