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 (