From b713ccac9f54256a3e383158ed8fb11d44a27177 Mon Sep 17 00:00:00 2001 From: tsmethurst Date: Fri, 9 Apr 2021 23:55:57 +0200 Subject: [PATCH] bleep bloop --- cmd/gotosocial/main.go | 12 ++ internal/apimodule/media/media.go | 6 +- internal/apimodule/media/mediacreate.go | 31 ++- internal/apimodule/media/mediacreate_test.go | 197 +++++++++++++++++++ internal/config/config.go | 26 ++- internal/config/media.go | 4 + internal/db/gtsmodel/poll.go | 2 - internal/mastotypes/converter.go | 26 +-- internal/media/media.go | 2 +- internal/storage/inmem.go | 5 + internal/storage/local.go | 2 + internal/storage/storage.go | 4 + internal/util/status.go | 4 +- testrig/models.go | 4 +- testrig/util.go | 48 +++++ 15 files changed, 337 insertions(+), 36 deletions(-) create mode 100644 internal/apimodule/media/mediacreate_test.go create mode 100644 testrig/util.go diff --git a/cmd/gotosocial/main.go b/cmd/gotosocial/main.go index 7f185d8d9..a768d955b 100644 --- a/cmd/gotosocial/main.go +++ b/cmd/gotosocial/main.go @@ -143,6 +143,18 @@ func main() { Value: 5242880, // 5mb EnvVars: []string{envNames.MediaMaxVideoSize}, }, + &cli.IntFlag{ + Name: flagNames.MediaMinDescriptionChars, + Usage: "Min required chars for an image description", + Value: 0, + EnvVars: []string{envNames.MediaMinDescriptionChars}, + }, + &cli.IntFlag{ + Name: flagNames.MediaMaxDescriptionChars, + Usage: "Max permitted chars for an image description", + Value: 500, + EnvVars: []string{envNames.MediaMaxDescriptionChars}, + }, // STORAGE FLAGS &cli.StringFlag{ diff --git a/internal/apimodule/media/media.go b/internal/apimodule/media/media.go index 6b2264c68..44848a3f2 100644 --- a/internal/apimodule/media/media.go +++ b/internal/apimodule/media/media.go @@ -32,7 +32,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/router" ) -const mediaPath = "/api/v1/media" +const basePath = "/api/v1/media" type mediaModule struct { mediaHandler media.MediaHandler @@ -46,7 +46,7 @@ type mediaModule struct { 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, + config: config, db: db, mastoConverter: mastoConverter, log: log, @@ -55,7 +55,7 @@ func New(db db.DB, mediaHandler media.MediaHandler, mastoConverter mastotypes.Co // Route satisfies the RESTAPIModule interface func (m *mediaModule) Route(s router.Router) error { - s.AttachHandler(http.MethodPost, mediaPath, m.mediaCreatePOSTHandler) + s.AttachHandler(http.MethodPost, basePath, m.mediaCreatePOSTHandler) return nil } diff --git a/internal/apimodule/media/mediacreate.go b/internal/apimodule/media/mediacreate.go index d262a6e1a..0f465e815 100644 --- a/internal/apimodule/media/mediacreate.go +++ b/internal/apimodule/media/mediacreate.go @@ -67,14 +67,13 @@ func (m *mediaModule) mediaCreatePOSTHandler(c *gin.Context) { 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 } - - // extract the bytes buf := new(bytes.Buffer) size, err := io.Copy(buf, f) if err != nil { @@ -88,6 +87,7 @@ func (m *mediaModule) mediaCreatePOSTHandler(c *gin.Context) { 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.ProcessAttachment(buf.Bytes(), authed.Account.ID) if err != nil { l.Debugf("error reading attachment: %s", err) @@ -95,7 +95,14 @@ func (m *mediaModule) mediaCreatePOSTHandler(c *gin.Context) { 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, ",") @@ -106,12 +113,12 @@ func (m *mediaModule) mediaCreatePOSTHandler(c *gin.Context) { } xStr := spl[0] yStr := spl[1] - if xStr == "" || xStr == "" { + 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[:4], 32) + 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)}) @@ -123,7 +130,7 @@ func (m *mediaModule) mediaCreatePOSTHandler(c *gin.Context) { return } focusx = float32(fx) - fy, err := strconv.ParseFloat(yStr[:4], 32) + 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)}) @@ -136,10 +143,11 @@ func (m *mediaModule) mediaCreatePOSTHandler(c *gin.Context) { } 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) @@ -147,12 +155,14 @@ func (m *mediaModule) mediaCreatePOSTHandler(c *gin.Context) { 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) } @@ -162,7 +172,7 @@ func validateCreateMedia(form *mastotypes.AttachmentRequest, config *config.Medi return errors.New("no attachment given") } - // a very superficial check to see if no limits are exceeded + // 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 { @@ -171,5 +181,12 @@ func validateCreateMedia(form *mastotypes.AttachmentRequest, config *config.Medi 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/mediacreate_test.go b/internal/apimodule/media/mediacreate_test.go new file mode 100644 index 000000000..de1091b7a --- /dev/null +++ b/internal/apimodule/media/mediacreate_test.go @@ -0,0 +1,197 @@ +package media + +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/mock" + "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" + 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 { + suite.Suite + config *config.Config + mockOauthServer *oauth.MockServer + mockStorage *storage.MockStorage + mediaHandler media.MediaHandler + mastoConverter mastotypes.Converter + 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 + mediaModule *mediaModule +} + +/* + TEST INFRASTRUCTURE +*/ + +// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout +func (suite *MediaCreateTestSuite) SetupSuite() { + // some of our subsequent entities need a log so create this here + log := logrus.New() + log.SetLevel(logrus.TraceLevel) + suite.log = log + + // 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.mockStorage.On("StoreFileAt", mock.AnythingOfType("string"), mock.AnythingOfType("[]uint8")).Return(nil) // just pretend to store + suite.mediaHandler = media.New(suite.config, suite.db, suite.mockStorage, log) + suite.mastoConverter = mastotypes.New(suite.config, suite.db) + suite.mediaModule = New(suite.db, suite.mediaHandler, suite.mastoConverter, suite.config, suite.log).(*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() { + if err := testrig.StandardDBSetup(suite.db); err != nil { + panic(err) + } + suite.testTokens = testrig.TestTokens() + suite.testClients = testrig.TestClients() + suite.testApplications = testrig.TestApplications() + suite.testUsers = testrig.TestUsers() + suite.testAccounts = testrig.TestAccounts() +} + +// TearDownTest drops tables to make sure there's no data in the db +func (suite *MediaCreateTestSuite) TearDownTest() { + if err := testrig.StandardDBTeardown(suite.db); err != nil { + panic(err) + } +} + +/* + ACTUAL TESTS +*/ + +func (suite *MediaCreateTestSuite) TestStatusCreatePOSTImageHandlerSuccessful() { + + t := suite.testTokens["local_account_1"] + oauthToken := oauth.PGTokenToOauthToken(t) + + // setup + 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"]) + buf, w, err := testrig.CreateMultipartFormData("file", "../../media/test/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", basePath), bytes.NewReader(buf.Bytes())) // the endpoint we're hitting + ctx.Request.Header.Set("Content-Type", w.FormDataContentType()) + suite.mediaModule.mediaCreatePOSTHandler(ctx) + + // 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) +} + +func TestMediaCreateTestSuite(t *testing.T) { + suite.Run(t, new(MediaCreateTestSuite)) +} diff --git a/internal/config/config.go b/internal/config/config.go index 4cb2b901f..5d2e8c43e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -155,6 +155,14 @@ func (c *Config) ParseCLIFlags(f KeyedFlags) { c.MediaConfig.MaxVideoSize = f.Int(fn.MediaMaxVideoSize) } + if c.MediaConfig.MinDescriptionChars == 0 || f.IsSet(fn.MediaMinDescriptionChars) { + c.MediaConfig.MinDescriptionChars = f.Int(fn.MediaMinDescriptionChars) + } + + if c.MediaConfig.MaxDescriptionChars == 0 || f.IsSet(fn.MediaMaxDescriptionChars) { + c.MediaConfig.MaxDescriptionChars = f.Int(fn.MediaMaxDescriptionChars) + } + // storage flags if c.StorageConfig.Backend == "" || f.IsSet(fn.StorageBackend) { c.StorageConfig.Backend = f.String(fn.StorageBackend) @@ -224,8 +232,10 @@ type Flags struct { AccountsOpenRegistration string AccountsRequireApproval string - MediaMaxImageSize string - MediaMaxVideoSize string + MediaMaxImageSize string + MediaMaxVideoSize string + MediaMinDescriptionChars string + MediaMaxDescriptionChars string StorageBackend string StorageBasePath string @@ -262,8 +272,10 @@ func GetFlagNames() Flags { AccountsOpenRegistration: "accounts-open-registration", AccountsRequireApproval: "accounts-require-approval", - MediaMaxImageSize: "media-max-image-size", - MediaMaxVideoSize: "media-max-video-size", + MediaMaxImageSize: "media-max-image-size", + MediaMaxVideoSize: "media-max-video-size", + MediaMinDescriptionChars: "media-min-description-chars", + MediaMaxDescriptionChars: "media-max-description-chars", StorageBackend: "storage-backend", StorageBasePath: "storage-base-path", @@ -301,8 +313,10 @@ func GetEnvNames() Flags { AccountsOpenRegistration: "GTS_ACCOUNTS_OPEN_REGISTRATION", AccountsRequireApproval: "GTS_ACCOUNTS_REQUIRE_APPROVAL", - MediaMaxImageSize: "GTS_MEDIA_MAX_IMAGE_SIZE", - MediaMaxVideoSize: "GTS_MEDIA_MAX_VIDEO_SIZE", + MediaMaxImageSize: "GTS_MEDIA_MAX_IMAGE_SIZE", + MediaMaxVideoSize: "GTS_MEDIA_MAX_VIDEO_SIZE", + MediaMinDescriptionChars: "GTS_MEDIA_MIN_DESCRIPTION_CHARS", + MediaMaxDescriptionChars: "GTS_MEDIA_MAX_DESCRIPTION_CHARS", StorageBackend: "GTS_STORAGE_BACKEND", StorageBasePath: "GTS_STORAGE_BASE_PATH", diff --git a/internal/config/media.go b/internal/config/media.go index 816e236b2..136dba528 100644 --- a/internal/config/media.go +++ b/internal/config/media.go @@ -24,4 +24,8 @@ type MediaConfig struct { MaxImageSize int `yaml:"maxImageSize"` // Max size of uploaded video in bytes MaxVideoSize int `yaml:"maxVideoSize"` + // Minimum amount of chars required in an image description + MinDescriptionChars int `yaml:"minDescriptionChars"` + // Max amount of chars allowed in an image description + MaxDescriptionChars int `yaml:"maxDescriptionChars"` } diff --git a/internal/db/gtsmodel/poll.go b/internal/db/gtsmodel/poll.go index bc0fefaa7..c39497cdd 100644 --- a/internal/db/gtsmodel/poll.go +++ b/internal/db/gtsmodel/poll.go @@ -17,5 +17,3 @@ */ package gtsmodel - - diff --git a/internal/mastotypes/converter.go b/internal/mastotypes/converter.go index da153a72d..0e81f1a06 100644 --- a/internal/mastotypes/converter.go +++ b/internal/mastotypes/converter.go @@ -236,23 +236,23 @@ func (c *converter) AppToMastoPublic(a *gtsmodel.Application) (*mastotypes.Appli func (c *converter) AttachmentToMasto(a *gtsmodel.MediaAttachment) (mastotypes.Attachment, error) { return mastotypes.Attachment{ - ID: a.ID, - Type: string(a.Type), - URL: a.URL, - PreviewURL: a.Thumbnail.URL, - RemoteURL: a.RemoteURL, + ID: a.ID, + Type: string(a.Type), + URL: a.URL, + PreviewURL: a.Thumbnail.URL, + RemoteURL: a.RemoteURL, PreviewRemoteURL: a.Thumbnail.RemoteURL, Meta: mastotypes.MediaMeta{ Original: mastotypes.MediaDimensions{ - Width: a.FileMeta.Original.Width, + Width: a.FileMeta.Original.Width, Height: a.FileMeta.Original.Height, - Size: fmt.Sprintf("%dx%d", a.FileMeta.Original.Width, a.FileMeta.Original.Height), + Size: fmt.Sprintf("%dx%d", a.FileMeta.Original.Width, a.FileMeta.Original.Height), Aspect: float32(a.FileMeta.Original.Aspect), }, Small: mastotypes.MediaDimensions{ - Width: a.FileMeta.Small.Width, + Width: a.FileMeta.Small.Width, Height: a.FileMeta.Small.Height, - Size: fmt.Sprintf("%dx%d", a.FileMeta.Small.Width, a.FileMeta.Small.Height), + Size: fmt.Sprintf("%dx%d", a.FileMeta.Small.Width, a.FileMeta.Small.Height), Aspect: float32(a.FileMeta.Small.Aspect), }, Focus: mastotypes.MediaFocus{ @@ -261,7 +261,7 @@ func (c *converter) AttachmentToMasto(a *gtsmodel.MediaAttachment) (mastotypes.A }, }, Description: a.Description, - Blurhash: a.Blurhash, + Blurhash: a.Blurhash, }, nil } @@ -284,9 +284,9 @@ func (c *converter) MentionToMasto(m *gtsmodel.Mention) (mastotypes.Mention, err } return mastotypes.Mention{ - ID: m.ID, + ID: m.ID, Username: target.Username, - URL: target.URL, - Acct: acct, + URL: target.URL, + Acct: acct, }, nil } diff --git a/internal/media/media.go b/internal/media/media.go index 0e60c2c09..02acef1f9 100644 --- a/internal/media/media.go +++ b/internal/media/media.go @@ -113,7 +113,7 @@ func (mh *mediaHandler) ProcessAttachment(data []byte, accountID string) (*gtsmo if err != nil { return nil, err } - mainType := strings.Split(contentType, "/")[0] + mainType := strings.Split(contentType, "/")[0] switch mainType { case "video": if !supportedVideoType(contentType) { diff --git a/internal/storage/inmem.go b/internal/storage/inmem.go index 25432fbaa..31c2bd99a 100644 --- a/internal/storage/inmem.go +++ b/internal/storage/inmem.go @@ -7,6 +7,11 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/config" ) +// NewInMem returns an in-memory implementation of the Storage interface. +// This is good for testing and whatnot but ***SHOULD ABSOLUTELY NOT EVER +// BE USED IN A PRODUCTION SETTING***, because A) everything will be wiped out +// if you restart the server and B) if you store lots of images your RAM use +// will absolutely go through the roof. func NewInMem(c *config.Config, log *logrus.Logger) (Storage, error) { return &inMemStorage{ stored: make(map[string][]byte), diff --git a/internal/storage/local.go b/internal/storage/local.go index 29461d5d4..620467df1 100644 --- a/internal/storage/local.go +++ b/internal/storage/local.go @@ -5,6 +5,8 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/config" ) +// NewLocal returns an implementation of the Storage interface that uses +// the local filesystem for storing and retrieving files, attachments, etc. func NewLocal(c *config.Config, log *logrus.Logger) (Storage, error) { return &localStorage{}, nil } diff --git a/internal/storage/storage.go b/internal/storage/storage.go index fa884ed07..7c85d0a5b 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -16,8 +16,12 @@ along with this program. If not, see . */ +// Package storage contains an interface and implementations for storing and retrieving files and attachments. package storage +// Storage is an interface for storing and retrieving blobs +// such as images, videos, and any other attachments/documents +// that shouldn't be stored in a database. type Storage interface { StoreFileAt(path string, data []byte) error RetrieveFileFrom(path string) ([]byte, error) diff --git a/internal/util/status.go b/internal/util/status.go index bc091b3d8..7e4e669bf 100644 --- a/internal/util/status.go +++ b/internal/util/status.go @@ -25,8 +25,8 @@ import ( var ( // mention regex can be played around with here: https://regex101.com/r/qwM9D3/1 - mentionRegexString = `(?: |^|\W)(@[a-zA-Z0-9_]+(?:@[a-zA-Z0-9_\-\.]+)?)(?: |\n)` - mentionRegex = regexp.MustCompile(mentionRegexString) + mentionRegexString = `(?: |^|\W)(@[a-zA-Z0-9_]+(?:@[a-zA-Z0-9_\-\.]+)?)(?: |\n)` + mentionRegex = regexp.MustCompile(mentionRegexString) // hashtag regex can be played with here: https://regex101.com/r/Vhy8pg/1 hashtagRegexString = `(?: |^|\W)?#([a-zA-Z0-9]{1,30})(?:\b|\r)` hashtagRegex = regexp.MustCompile(hashtagRegexString) diff --git a/testrig/models.go b/testrig/models.go index 7cb24cbab..8599c970a 100644 --- a/testrig/models.go +++ b/testrig/models.go @@ -442,9 +442,9 @@ func TestAccounts() map[string]*gtsmodel.Account { func TestAttachments() map[string]*gtsmodel.MediaAttachment { return map[string]*gtsmodel.MediaAttachment{ - "admin_account_status_1": { + // "admin_account_status_1": { - }, + // }, } } diff --git a/testrig/util.go b/testrig/util.go new file mode 100644 index 000000000..e9f1f3e17 --- /dev/null +++ b/testrig/util.go @@ -0,0 +1,48 @@ +package testrig + +import ( + "bytes" + "io" + "mime/multipart" + "os" +) + +// CreateMultipartFormData is a handy function for taking a fieldname and a filename, and creating a multipart form bytes buffer +// with the file contents set in the given fieldname. The extraFields param can be used to add extra FormFields to the request, as necessary. +// The returned bytes.Buffer b can be used like so: +// httptest.NewRequest(http.MethodPost, "https://example.org/whateverpath", bytes.NewReader(b.Bytes())) +// The returned *multipart.Writer w can be used to set the content type of the request, like so: +// req.Header.Set("Content-Type", w.FormDataContentType()) +func CreateMultipartFormData(fieldName string, fileName string, extraFields map[string]string) (bytes.Buffer, *multipart.Writer, error) { + var b bytes.Buffer + var err error + w := multipart.NewWriter(&b) + var fw io.Writer + file, err := os.Open(fileName) + if err != nil { + return b, nil, err + } + if fw, err = w.CreateFormFile(fieldName, file.Name()); err != nil { + return b, nil, err + } + if _, err = io.Copy(fw, file); err != nil { + return b, nil, err + } + + if extraFields != nil { + for k, v := range extraFields { + f, err := w.CreateFormField(k) + if err != nil { + return b, nil, err + } + if _, err := io.Copy(f, bytes.NewBufferString(v)); err != nil { + return b, nil, err + } + } + } + + if err := w.Close(); err != nil { + return b, nil, err + } + return b, w, nil +}