diff --git a/PROGRESS.md b/PROGRESS.md index 2d9c653a8..89ada4aa7 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -69,7 +69,7 @@ * [ ] /api/v1/suggestions GET (Get suggested accounts to follow) * [ ] /api/v1/suggestions/:account_id DELETE (Delete a suggestion) * [ ] Statuses - * [ ] /api/v1/statuses POST (Create a new status) + * [x] /api/v1/statuses POST (Create a new status) * [ ] /api/v1/statuses/:id GET (View an existing status) * [ ] /api/v1/statuses/:id DELETE (Delete a status) * [ ] /api/v1/statuses/:id/context GET (View statuses above and below status ID) @@ -86,7 +86,7 @@ * [ ] /api/v1/statuses/:id/pin POST (Pin a status to profile) * [ ] /api/v1/statuses/:id/unpin POST (Unpin a status from profile) * [ ] Media - * [ ] /api/v1/media POST (Upload a media attachment) + * [x] /api/v1/media POST (Upload a media attachment) * [ ] /api/v1/media/:id GET (Get a media attachment) * [ ] /api/v1/media/:id PUT (Update an attachment) * [ ] Polls @@ -178,8 +178,8 @@ * [ ] Storage * [x] Internal/statuses/preferences etc * [x] Postgres interface - * [ ] Media storage - * [ ] Local storage interface + * [x] Media storage + * [x] Local storage interface * [ ] S3 storage interface * [ ] Cache * [ ] In-memory cache diff --git a/cmd/gotosocial/main.go b/cmd/gotosocial/main.go index 428481720..43d2d2ac7 100644 --- a/cmd/gotosocial/main.go +++ b/cmd/gotosocial/main.go @@ -100,7 +100,7 @@ func main() { &cli.StringFlag{ Name: flagNames.DbPassword, Usage: "Database password", - Value: defaults.DbPassword, + Value: defaults.DbPassword, EnvVars: []string{envNames.DbPassword}, }, &cli.StringFlag{ diff --git a/internal/apimodule/auth/auth_test.go b/internal/apimodule/auth/auth_test.go index 351c086e4..2c272e985 100644 --- a/internal/apimodule/auth/auth_test.go +++ b/internal/apimodule/auth/auth_test.go @@ -22,7 +22,6 @@ import ( "context" "fmt" "testing" - "time" "github.com/google/uuid" "github.com/sirupsen/logrus" @@ -31,7 +30,6 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/router" "golang.org/x/crypto/bcrypt" ) @@ -163,27 +161,6 @@ func (suite *AuthTestSuite) TearDownTest() { suite.db = nil } -func (suite *AuthTestSuite) TestAPIInitialize() { - log := logrus.New() - log.SetLevel(logrus.TraceLevel) - - r, err := router.New(suite.config, log) - if err != nil { - suite.FailNow(fmt.Sprintf("error mapping routes onto router: %s", err)) - } - - api := New(suite.oauthServer, suite.db, log) - if err := api.Route(r); err != nil { - suite.FailNow(fmt.Sprintf("error mapping routes onto router: %s", err)) - } - - r.Start() - time.Sleep(60 * time.Second) - if err := r.Stop(context.Background()); err != nil { - suite.FailNow(fmt.Sprintf("error stopping router: %s", err)) - } -} - func TestAuthTestSuite(t *testing.T) { suite.Run(t, new(AuthTestSuite)) } diff --git a/internal/apimodule/fileserver/fileserver.go b/internal/apimodule/fileserver/fileserver.go index c82c9bbf1..6d15b4e9e 100644 --- a/internal/apimodule/fileserver/fileserver.go +++ b/internal/apimodule/fileserver/fileserver.go @@ -1,7 +1,26 @@ +/* + 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 fileserver import ( "fmt" + "net/http" "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/apimodule" @@ -12,6 +31,13 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/storage" ) +const ( + accountIDKey = "account_id" + mediaTypeKey = "media_type" + mediaSizeKey = "media_size" + fileNameKey = "file_name" +) + // fileServer implements the RESTAPIModule interface. // The goal here is to serve requested media files if the gotosocial server is configured to use local storage. type fileServer struct { @@ -24,33 +50,23 @@ type fileServer struct { // New returns a new fileServer module func New(config *config.Config, db db.DB, storage storage.Storage, log *logrus.Logger) apimodule.ClientAPIModule { - - storageBase := config.StorageConfig.BasePath // TODO: do this properly - return &fileServer{ config: config, db: db, storage: storage, log: log, - storageBase: storageBase, + storageBase: config.StorageConfig.ServeBasePath, } } // Route satisfies the RESTAPIModule interface func (m *fileServer) Route(s router.Router) error { - // s.AttachHandler(http.MethodPost, appsPath, m.appsPOSTHandler) + s.AttachHandler(http.MethodGet, fmt.Sprintf("%s/:%s/:%s/:%s/:%s", m.storageBase, accountIDKey, mediaTypeKey, mediaSizeKey, fileNameKey), m.ServeFile) return nil } func (m *fileServer) CreateTables(db db.DB) error { models := []interface{}{ - >smodel.User{}, - >smodel.Account{}, - >smodel.Follow{}, - >smodel.FollowRequest{}, - >smodel.Status{}, - >smodel.Application{}, - >smodel.EmailDomainBlock{}, >smodel.MediaAttachment{}, } diff --git a/internal/apimodule/fileserver/servefile.go b/internal/apimodule/fileserver/servefile.go new file mode 100644 index 000000000..974867e17 --- /dev/null +++ b/internal/apimodule/fileserver/servefile.go @@ -0,0 +1,152 @@ +/* + 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 fileserver + +import ( + "bytes" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" +) + +// ServeFile is for serving attachments, headers, and avatars to the requester from instance storage. +// +// Note: to mitigate scraping attempts, no information should be given out on a bad request except "404 page not found". +// Don't give away account ids or media ids or anything like that; callers shouldn't be able to infer anything. +func (m *fileServer) ServeFile(c *gin.Context) { + l := m.log.WithFields(logrus.Fields{ + "func": "ServeFile", + "request_uri": c.Request.RequestURI, + "user_agent": c.Request.UserAgent(), + "origin_ip": c.ClientIP(), + }) + l.Trace("received request") + + // We use request params to check what to pull out of the database/storage so check everything. A request URL should be formatted as follows: + // "https://example.org/fileserver/[ACCOUNT_ID]/[MEDIA_TYPE]/[MEDIA_SIZE]/[FILE_NAME]" + // "FILE_NAME" consists of two parts, the attachment's database id, a period, and the file extension. + accountID := c.Param(accountIDKey) + if accountID == "" { + l.Debug("missing accountID from request") + c.String(http.StatusNotFound, "404 page not found") + return + } + + mediaType := c.Param(mediaTypeKey) + if mediaType == "" { + l.Debug("missing mediaType from request") + c.String(http.StatusNotFound, "404 page not found") + return + } + + mediaSize := c.Param(mediaSizeKey) + if mediaSize == "" { + l.Debug("missing mediaSize from request") + c.String(http.StatusNotFound, "404 page not found") + return + } + + fileName := c.Param(fileNameKey) + if fileName == "" { + l.Debug("missing fileName from request") + c.String(http.StatusNotFound, "404 page not found") + return + } + + // Only serve media types that are defined in our internal media module + switch mediaType { + case media.MediaHeader: + case media.MediaAvatar: + case media.MediaAttachment: + default: + l.Debugf("mediatype %s not recognized", mediaType) + c.String(http.StatusNotFound, "404 page not found") + return + } + + // This corresponds to original-sized image as it was uploaded, or small, which is the thumbnail + switch mediaSize { + case media.MediaOriginal: + case media.MediaSmall: + default: + l.Debugf("mediasize %s not recognized", mediaSize) + c.String(http.StatusNotFound, "404 page not found") + return + } + + // derive the media id and the file extension from the last part of the request + spl := strings.Split(fileName, ".") + if len(spl) != 2 { + l.Debugf("filename %s not parseable", fileName) + c.String(http.StatusNotFound, "404 page not found") + return + } + wantedMediaID := spl[0] + fileExtension := spl[1] + if wantedMediaID == "" || fileExtension == "" { + l.Debugf("filename %s not parseable", fileName) + c.String(http.StatusNotFound, "404 page not found") + return + } + + // now we know the attachment ID that the caller is asking for we can use it to pull the attachment out of the db + attachment := >smodel.MediaAttachment{} + if err := m.db.GetByID(wantedMediaID, attachment); err != nil { + l.Debugf("attachment with id %s not retrievable: %s", wantedMediaID, err) + c.String(http.StatusNotFound, "404 page not found") + return + } + + // make sure the given account id owns the requested attachment + if accountID != attachment.AccountID { + l.Debugf("account %s does not own attachment with id %s", accountID, wantedMediaID) + c.String(http.StatusNotFound, "404 page not found") + return + } + + // now we can start preparing the response depending on whether we're serving a thumbnail or a larger attachment + var storagePath string + var contentType string + var contentLength int + switch mediaSize { + case media.MediaOriginal: + storagePath = attachment.File.Path + contentType = attachment.File.ContentType + contentLength = attachment.File.FileSize + case media.MediaSmall: + storagePath = attachment.Thumbnail.Path + contentType = attachment.Thumbnail.ContentType + contentLength = attachment.Thumbnail.FileSize + } + + // use the path listed on the attachment we pulled out of the database to retrieve the object from storage + attachmentBytes, err := m.storage.RetrieveFileFrom(storagePath) + if err != nil { + l.Debugf("error retrieving from storage: %s", err) + c.String(http.StatusNotFound, "404 page not found") + return + } + + // finally we can return with all the information we derived above + c.DataFromReader(http.StatusOK, int64(contentLength), contentType, bytes.NewReader(attachmentBytes), map[string]string{}) +} diff --git a/internal/apimodule/fileserver/servefile_test.go b/internal/apimodule/fileserver/servefile_test.go new file mode 100644 index 000000000..9b744a8f6 --- /dev/null +++ b/internal/apimodule/fileserver/servefile_test.go @@ -0,0 +1,156 @@ +/* + 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 fileserver + +import ( + "context" + "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" + "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/oauth" + "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type ServeFileTestSuite 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 + fileServer *fileServer +} + +/* + TEST INFRASTRUCTURE +*/ + +func (suite *ServeFileTestSuite) 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.fileServer = New(suite.config, suite.db, suite.storage, suite.log).(*fileServer) +} + +func (suite *ServeFileTestSuite) TearDownSuite() { + if err := suite.db.Stop(context.Background()); err != nil { + logrus.Panicf("error closing db connection: %s", err) + } +} + +func (suite *ServeFileTestSuite) 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 *ServeFileTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} + +/* + ACTUAL TESTS +*/ + +func (suite *ServeFileTestSuite) TestServeOriginalFileSuccessful() { + targetAttachment, ok := suite.testAttachments["admin_account_status_1_attachment_1"] + assert.True(suite.T(), ok) + assert.NotNil(suite.T(), targetAttachment) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodGet, targetAttachment.URL, nil) + + // normally the router would populate these params from the path values, + // but because we're calling the ServeFile function directly, we need to set them manually. + ctx.Params = gin.Params{ + gin.Param{ + Key: accountIDKey, + Value: targetAttachment.AccountID, + }, + gin.Param{ + Key: mediaTypeKey, + Value: media.MediaAttachment, + }, + gin.Param{ + Key: mediaSizeKey, + Value: media.MediaOriginal, + }, + gin.Param{ + Key: fileNameKey, + Value: fmt.Sprintf("%s.jpeg", targetAttachment.ID), + }, + } + + // call the function we're testing and check status code + suite.fileServer.ServeFile(ctx) + suite.EqualValues(http.StatusOK, recorder.Code) + + b, err := ioutil.ReadAll(recorder.Body) + assert.NoError(suite.T(), err) + assert.NotNil(suite.T(), b) + + fileInStorage, err := suite.storage.RetrieveFileFrom(targetAttachment.File.Path) + assert.NoError(suite.T(), err) + assert.NotNil(suite.T(), fileInStorage) + assert.Equal(suite.T(), b, fileInStorage) +} + +func TestServeFileTestSuite(t *testing.T) { + suite.Run(t, new(ServeFileTestSuite)) +} diff --git a/internal/apimodule/media/mediacreate_test.go b/internal/apimodule/media/mediacreate_test.go index 0c5c53340..ab77a9a3d 100644 --- a/internal/apimodule/media/mediacreate_test.go +++ b/internal/apimodule/media/mediacreate_test.go @@ -1,3 +1,21 @@ +/* + 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 ( @@ -167,7 +185,7 @@ func (suite *MediaCreateTestSuite) TestStatusCreatePOSTImageHandlerSuccessful() 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 + 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) { diff --git a/internal/apimodule/status/statuscreate.go b/internal/apimodule/status/statuscreate.go index 5687bacbf..b814affaf 100644 --- a/internal/apimodule/status/statuscreate.go +++ b/internal/apimodule/status/statuscreate.go @@ -169,13 +169,27 @@ func (m *statusModule) statusCreatePOSTHandler(c *gin.Context) { } newStatus.Emojis = emojis - // put the new status in the database + /* + FROM THIS POINT ONWARDS WE ARE HAPPY WITH THE STATUS -- it is valid and we will try to create it + */ + + // put the new status in the database, generating an ID for it in the process if err := m.db.Put(newStatus); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - // pass to the distributor to take care of side effects -- federation, mentions, updating metadata, etc, etc + // change the status ID of the media attachments to the new status + for _, a := range newStatus.Attachments { + a.StatusID = newStatus.ID + a.UpdatedAt = time.Now() + if err := m.db.UpdateByID(a.ID, a); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + + // pass to the distributor to take care of side effects asynchronously -- federation, mentions, updating metadata, etc, etc m.distributor.FromClientAPI() <- distributor.FromClientAPI{ APObjectType: gtsmodel.ActivityStreamsNote, APActivityType: gtsmodel.ActivityStreamsCreate, @@ -430,6 +444,10 @@ func (m *statusModule) parseMediaIDs(form *advancedStatusCreateForm, thisAccount if a.AccountID != thisAccountID { return fmt.Errorf("media with id %s does not belong to account %s", mediaID, thisAccountID) } + // check they're not already used in a status + if a.StatusID != "" || a.ScheduledStatusID != "" { + return fmt.Errorf("media with id %s is already attached to a status", mediaID) + } attachments = append(attachments, a) } status.Attachments = attachments diff --git a/internal/apimodule/status/statuscreate_test.go b/internal/apimodule/status/statuscreate_test.go index 40fd5822f..999481f66 100644 --- a/internal/apimodule/status/statuscreate_test.go +++ b/internal/apimodule/status/statuscreate_test.go @@ -19,7 +19,6 @@ package status import ( - "context" "encoding/json" "fmt" "io/ioutil" @@ -45,21 +44,27 @@ import ( ) type StatusCreateTestSuite struct { + // standard suite interfaces suite.Suite - config *config.Config - mockOauthServer *oauth.MockServer - mockStorage *storage.MockStorage - mediaHandler media.MediaHandler - mastoConverter mastotypes.Converter - distributor *distributor.MockDistributor + config *config.Config + db db.DB + log *logrus.Logger + storage storage.Storage + mastoConverter mastotypes.Converter + mediaHandler media.MediaHandler + oauthServer oauth.Server + distributor distributor.Distributor + + // 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 - log *logrus.Logger - db db.DB - statusModule *statusModule + testAttachments map[string]*gtsmodel.MediaAttachment + + // module being tested + statusModule *statusModule } /* @@ -68,73 +73,34 @@ type StatusCreateTestSuite struct { // SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout func (suite *StatusCreateTestSuite) SetupSuite() { - // some of our subsequent entities need a log so create this here - log := logrus.New() - log.SetLevel(logrus.TraceLevel) - suite.log = log + // 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) + suite.distributor = testrig.NewTestDistributor() - // Direct config to local postgres instance - c := config.Empty() - c.Protocol = "http" - c.Host = "localhost" - c.DBConfig = &config.DBConfig{ - Type: "postgres", - Address: "localhost", - Port: 5432, - User: "postgres", - Password: "postgres", - Database: "postgres", - ApplicationName: "gotosocial", - } - c.MediaConfig = &config.MediaConfig{ - MaxImageSize: 2 << 20, - } - c.StorageConfig = &config.StorageConfig{ - Backend: "local", - BasePath: "/tmp", - ServeProtocol: "http", - ServeHost: "localhost", - ServeBasePath: "/fileserver/media", - } - c.StatusesConfig = &config.StatusesConfig{ - MaxChars: 500, - CWMaxChars: 50, - PollMaxOptions: 4, - PollOptionMaxChars: 50, - MaxMediaFiles: 4, - } - suite.config = c - - // use an actual database for this, because it's just easier than mocking one out - database, err := db.New(context.Background(), c, log) - if err != nil { - suite.FailNow(err.Error()) - } - suite.db = database - - suite.mockOauthServer = &oauth.MockServer{} - suite.mockStorage = &storage.MockStorage{} - suite.mediaHandler = media.New(suite.config, suite.db, suite.mockStorage, log) - suite.mastoConverter = mastotypes.New(suite.config, suite.db) - suite.distributor = &distributor.MockDistributor{} - suite.distributor.On("FromClientAPI").Return(make(chan distributor.FromClientAPI, 100)) - - suite.statusModule = New(suite.config, suite.db, suite.mockOauthServer, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*statusModule) + // setup module being tested + suite.statusModule = New(suite.config, suite.db, suite.oauthServer, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*statusModule) } func (suite *StatusCreateTestSuite) TearDownSuite() { - if err := suite.db.Stop(context.Background()); err != nil { - logrus.Panicf("error closing db connection: %s", err) - } + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) } func (suite *StatusCreateTestSuite) 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() } // TearDownTest drops tables to make sure there's no data in the db diff --git a/internal/config/config.go b/internal/config/config.go index d58650a03..a21eaa589 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -63,8 +63,6 @@ func Empty() *Config { } } - - // loadFromFile takes a path to a yaml file and attempts to load a Config object from it func loadFromFile(path string) (*Config, error) { bytes, err := os.ReadFile(path) diff --git a/internal/gotosocial/actions.go b/internal/gotosocial/actions.go index 2e745e7ca..2a327a8c3 100644 --- a/internal/gotosocial/actions.go +++ b/internal/gotosocial/actions.go @@ -31,6 +31,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/apimodule/account" "github.com/superseriousbusiness/gotosocial/internal/apimodule/app" "github.com/superseriousbusiness/gotosocial/internal/apimodule/auth" + "github.com/superseriousbusiness/gotosocial/internal/apimodule/fileserver" mediaModule "github.com/superseriousbusiness/gotosocial/internal/apimodule/media" "github.com/superseriousbusiness/gotosocial/internal/cache" "github.com/superseriousbusiness/gotosocial/internal/config" @@ -72,12 +73,14 @@ var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logr accountModule := account.New(c, dbService, oauthServer, mediaHandler, mastoConverter, log) appsModule := app.New(oauthServer, dbService, mastoConverter, log) mm := mediaModule.New(dbService, mediaHandler, mastoConverter, c, log) + fileServerModule := fileserver.New(c, dbService, storageBackend, log) apiModules := []apimodule.ClientAPIModule{ authModule, // this one has to go first so the other modules use its middleware accountModule, appsModule, mm, + fileServerModule, } for _, m := range apiModules { diff --git a/internal/media/media.go b/internal/media/media.go index 02acef1f9..2f877d5be 100644 --- a/internal/media/media.go +++ b/internal/media/media.go @@ -32,6 +32,14 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/storage" ) +const ( + MediaSmall = "small" + MediaOriginal = "original" + MediaAttachment = "attachment" + MediaHeader = "header" + MediaAvatar = "avatar" +) + // MediaHandler provides an interface for parsing, storing, and retrieving media objects like photos, videos, and gifs. type MediaHandler interface { // SetHeaderOrAvatarForAccountID takes a new header image for an account, checks it out, removes exif data from it, @@ -61,14 +69,6 @@ func New(config *config.Config, database db.DB, storage storage.Storage, log *lo } } -// HeaderInfo wraps the urls at which a Header and a StaticHeader is available from the server. -type HeaderInfo struct { - // URL to the header - Header string - // Static version of the above (eg., a path to a still image if the header is a gif) - HeaderStatic string -} - /* INTERFACE FUNCTIONS */ @@ -76,7 +76,7 @@ type HeaderInfo struct { func (mh *mediaHandler) SetHeaderOrAvatarForAccountID(img []byte, accountID string, headerOrAvi string) (*gtsmodel.MediaAttachment, error) { l := mh.log.WithField("func", "SetHeaderForAccountID") - if headerOrAvi != "header" && headerOrAvi != "avatar" { + if headerOrAvi != MediaHeader && headerOrAvi != MediaAvatar { return nil, errors.New("header or avatar not selected") } @@ -189,13 +189,13 @@ func (mh *mediaHandler) processImage(data []byte, accountID string, contentType 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) + originalPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, MediaAttachment, MediaOriginal, 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) + smallPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, MediaAttachment, MediaSmall, newMediaID, extension) if err := mh.storage.StoreFileAt(smallPath, small.image); err != nil { return nil, fmt.Errorf("storage error: %s", err) } @@ -254,9 +254,9 @@ func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string var isAvatar bool switch headerOrAvi { - case "header": + case MediaHeader: isHeader = true - case "avatar": + case MediaAvatar: isAvatar = true default: return nil, errors.New("header or avatar not selected") @@ -299,13 +299,13 @@ func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string smallURL := fmt.Sprintf("%s/%s/%s/small/%s.%s", URLbase, accountID, headerOrAvi, newMediaID, extension) // we store the original... - originalPath := fmt.Sprintf("%s/%s/%s/original/%s.%s", mh.config.StorageConfig.BasePath, accountID, headerOrAvi, newMediaID, extension) + originalPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, headerOrAvi, MediaOriginal, 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/%s/small/%s.%s", mh.config.StorageConfig.BasePath, accountID, headerOrAvi, newMediaID, extension) + smallPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, headerOrAvi, MediaSmall, newMediaID, extension) if err := mh.storage.StoreFileAt(smallPath, small.image); err != nil { return nil, fmt.Errorf("storage error: %s", err) } diff --git a/internal/storage/inmem.go b/internal/storage/inmem.go index 976ddd7ff..2d88189db 100644 --- a/internal/storage/inmem.go +++ b/internal/storage/inmem.go @@ -15,13 +15,13 @@ import ( func NewInMem(c *config.Config, log *logrus.Logger) (Storage, error) { return &inMemStorage{ stored: make(map[string][]byte), - log: log, + log: log, }, nil } type inMemStorage struct { stored map[string][]byte - log *logrus.Logger + log *logrus.Logger } func (s *inMemStorage) StoreFileAt(path string, data []byte) error { @@ -41,7 +41,7 @@ func (s *inMemStorage) RetrieveFileFrom(path string) ([]byte, error) { return d, nil } -func (s *inMemStorage)ListKeys() ([]string, error) { +func (s *inMemStorage) ListKeys() ([]string, error) { keys := []string{} for k := range s.stored { keys = append(keys, k) diff --git a/internal/storage/local.go b/internal/storage/local.go index 09a62bef4..3b64524f6 100644 --- a/internal/storage/local.go +++ b/internal/storage/local.go @@ -28,7 +28,7 @@ func (s *localStorage) StoreFileAt(path string, data []byte) error { l := s.log.WithField("func", "StoreFileAt") l.Debugf("storing at path %s", path) components := strings.Split(path, "/") - dir := strings.Join(components[0:len(components) - 1], "/") + dir := strings.Join(components[0:len(components)-1], "/") if err := os.MkdirAll(dir, 0777); err != nil { return fmt.Errorf("error writing file at %s: %s", path, err) } diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 493c364e0..409c90b37 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -25,6 +25,6 @@ package storage type Storage interface { StoreFileAt(path string, data []byte) error RetrieveFileFrom(path string) ([]byte, error) - ListKeys() ([]string, error) - RemoveFileAt(path string) error + ListKeys() ([]string, error) + RemoveFileAt(path string) error } diff --git a/testrig/distributor.go b/testrig/distributor.go new file mode 100644 index 000000000..c37f3dd26 --- /dev/null +++ b/testrig/distributor.go @@ -0,0 +1,25 @@ +/* + 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 testrig + +import "github.com/superseriousbusiness/gotosocial/internal/distributor" + +func NewTestDistributor() distributor.Distributor { + return distributor.New(nil, NewTestLog()) +} diff --git a/testrig/testmodels.go b/testrig/testmodels.go index 65358eba4..8011d3062 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -472,8 +472,8 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { StatusID: "502ccd6f-0edf-48d7-9016-2dfa4d3714cd", URL: "http://localhost:8080/fileserver/8020dbb4-1e7b-4d99-a872-4cf94e64210f/attachment/original/b052241b-f30f-4dc6-92fc-2bad0be1f8d8.jpeg", RemoteURL: "", - CreatedAt: time.Now().Add(-71 * time.Hour), - UpdatedAt: time.Now().Add(-71 * time.Hour), + CreatedAt: time.Now().Add(-71 * time.Hour), + UpdatedAt: time.Now().Add(-71 * time.Hour), Type: gtsmodel.FileTypeImage, FileMeta: gtsmodel.FileMeta{ Original: gtsmodel.Original{ @@ -516,7 +516,7 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { // NewTestStored returns a map of filenames, keyed according to which attachment they pertain to. func NewTestStored() map[string]string { - return map[string]string { + return map[string]string{ "admin_account_status_1_attachment_1": "welcome-*.jpeg", } } diff --git a/testrig/util.go b/testrig/util.go index e9f1f3e17..e6342d93d 100644 --- a/testrig/util.go +++ b/testrig/util.go @@ -1,3 +1,21 @@ +/* + 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 testrig import (