MM-7633: Optimize memory utilization during file uploads (#9835)

* MM-7633: Optimize memory utilization during file uploads

Refactored the file upload code to reduce redundant buffering and stream
directly to the file store. Added tests.

Benchmark results:
```
levs-mbp:mattermost-server levb$  go test -v -run nothing -bench Upload -benchmem ./app
...
BenchmarkUploadFile/random-5Mb-gif-raw-ish_DoUploadFile-4         	      10	 122598031 ns/op	21211370 B/op	    1008 allocs/op
BenchmarkUploadFile/random-5Mb-gif-raw_UploadFileTask-4           	     100	  20211926 ns/op	 5678750 B/op	     126 allocs/op
BenchmarkUploadFile/random-5Mb-gif-UploadFiles-4                  	       2	1037051184 ns/op	81806360 B/op	 3705013 allocs/op
BenchmarkUploadFile/random-5Mb-gif-UploadFileTask-4               	       2	 933644431 ns/op	67015868 B/op	 3704410 allocs/op
BenchmarkUploadFile/random-2Mb-jpg-raw-ish_DoUploadFile-4         	     100	  13110509 ns/op	 6032614 B/op	    8052 allocs/op
BenchmarkUploadFile/random-2Mb-jpg-raw_UploadFileTask-4           	     100	  10729867 ns/op	 1738303 B/op	     125 allocs/op
BenchmarkUploadFile/random-2Mb-jpg-UploadFiles-4                  	       2	 925274912 ns/op	70326352 B/op	 3718856 allocs/op
BenchmarkUploadFile/random-2Mb-jpg-UploadFileTask-4               	       2	 995033336 ns/op	58113796 B/op	 3710943 allocs/op
BenchmarkUploadFile/zero-10Mb-raw-ish_DoUploadFile-4              	      30	  50777211 ns/op	54791929 B/op	    2714 allocs/op
BenchmarkUploadFile/zero-10Mb-raw_UploadFileTask-4                	      50	  36387339 ns/op	10503920 B/op	     126 allocs/op
BenchmarkUploadFile/zero-10Mb-UploadFiles-4                       	      30	  48657678 ns/op	54791948 B/op	    2719 allocs/op
BenchmarkUploadFile/zero-10Mb-UploadFileTask-4                    	      50	  37506467 ns/op	31492060 B/op	     131 allocs/op
...
```

https://mattermost.atlassian.net/browse/MM-7633 https://github.com/mattermost/mattermost-server/issues/7801

[Place an '[x]' (no spaces) in all applicable fields. Please remove unrelated fields.]
- [x] Added or updated unit tests (required for all new features)
- [ ] Added API documentation (required for all new APIs)
- [ ] All new/modified APIs include changes to the drivers
  *N/A*???
- [x] Includes text changes and localization file ([.../i18n/en.json](https://github.com/mattermost/mattermost-server/blob/master/i18n/en.json)) updates

Overview of changes:
- api4
    - Replaced `uploadFile` handler with `uploadFileStream` that reduces
      unnecessary buffering.
    - Added/refactored tests for the new API.
    - Refactored apitestlib/Check...Status functions.
- app
    - Added App.UploadFileTask, a more efficient refactor of UploadFile.
    - Consistently set `FileInfo.HasPreviewImage`
    - Added benchmarks for the new and prior implementations
    - Replaced passing around `*image.Image` with `image.Image` in the
      existing code.
- model
    - Added a more capable `client4.UploadFiles` API to match the new server
      API’s capabilities.
- I18n
    - Replaced `api.file.upload_file.bad_parse.app_error` with a more generic
      `api.file.upload_file.read_request.app_error`
- plugin
    - Removed type `plugin.multiPluginHookRunnerFunc` in favor of using
      `func(hooks Hooks) bool` explicitly, to help with testing
- tests
    - Added test files for testing images

Still remaining, but can be separate PRs - please let me know the preferred
course of action
    - Investigate JS client API - how does it do multipart?
    - Performance loss from old code on (small) image processing?
    - Deprecate the old functions, change other API implementations to use
      UploadFileTask

Definitely separate future PRs - should I file tickets foe these?
    - Only invoke t.readAll() if there are indeed applicable plugins to run
    - Find a way to leverage goexif buffer rather than re-reading

Suggested long-term improvements - should I file separate tickets for these?
- Actually allow uploading of large (GB-sized) files. This may require a
  change in how the file is passed to plugins.
- (Many) api4 tests should probably be subtests and share a server setup -
  will be much faster
- Performance improvements in image processing (goexif/thumbnail/preview)
  (maybe use https://mattermost.atlassian.net/browse/MM-10188 for this)

Questions:
1. I am commiting MBs of test images, are there better alternatives? I can
   probably create much less dense images that would take up considerably less
   space, even at pretty large sizes
2. I18n: Do I need to do anything special for the string change? Or just wait
   until it gets picked up and translated/updated?
3. The image dimensions are flipped in resulting FileInfo to match the actual
   orientation. Is this by design? Should add a test for it, perhaps?
4. What to do in the case of partial success? S3 but not DB, some files but not
   others? For now, just doing what the old code did, I think.
5. Make maxUploadDrainBytes configurable? Also, should this be the systemic
   behavior of all APIs with non-empty body? Otherwise dropped altogether?
   Check all other ioutil.ReadAll() from sockets. Find a way to set a total
   byte limit on request Body?

* WIP - Fixed for GetPluginsEnvironment() changes

* WIP - PR feedback

1. Refactored checkHTTPStatus to improve failure messages
2. Use `var name []type` rather than `name := ([]type)(nil)`
3. Replaced single-letter `p` with a more intention-revealing `part`
4. Added tests for full image size, `HasPreviewImage`

* WIP - rebased (c.Session->c.App.Session)

* WIP - PR feedback: eliminated use of Request.MultipartReader

Instead of hacking the request object to use r.MultipartReader now have own
functions `parseMultipartRequestHeader` and `multipartReader` eliminating the
need to hack the request object to use Request.MultipartReader limitations.

* WIP - PR feedback: UploadFileX with functional options

* WIP - PR feedback: style

* WIP - PR feedback: errors cleanup

* WIP - clarified File Upload benchmarks

* WIP - PR feedback: display the value of erroneous formname

* WIP - PR feedback: fixed handling of multiple channel_ids

* WIP - rebased from master - fixed tests

* PR Feedback

* PR feedback - moved client4.UploadFiles to _test for now
This commit is contained in:
Lev
2018-12-13 13:32:07 -08:00
committed by Carlos Tadeu Panato Junior
parent 86fa3866c1
commit fae4f60b33
36 changed files with 1819 additions and 346 deletions

View File

@@ -573,103 +573,71 @@ func CheckNoError(t *testing.T, resp *model.Response) {
t.Helper()
if resp.Error != nil {
t.Fatal("Expected no error, got " + resp.Error.Error())
t.Fatalf("Expected no error, got %q", resp.Error.Error())
}
}
func CheckCreatedStatus(t *testing.T, resp *model.Response) {
func checkHTTPStatus(t *testing.T, resp *model.Response, expectedStatus int, expectError bool) {
t.Helper()
if resp.StatusCode != http.StatusCreated {
t.Log("actual: " + strconv.Itoa(resp.StatusCode))
t.Log("expected: " + strconv.Itoa(http.StatusCreated))
t.Fatal("wrong status code")
}
}
switch {
case resp == nil:
t.Fatalf("Unexpected nil response, expected http:%v, expectError:%v)", expectedStatus, expectError)
func CheckForbiddenStatus(t *testing.T, resp *model.Response) {
t.Helper()
case expectError && resp.Error == nil:
t.Fatalf("Expected a non-nil error and http status:%v, got nil, %v", expectedStatus, resp.StatusCode)
if resp.Error == nil {
t.Fatal("should have errored with status:" + strconv.Itoa(http.StatusForbidden))
return
}
case !expectError && resp.Error != nil:
t.Fatalf("Expected no error and http status:%v, got %q, http:%v", expectedStatus, resp.Error, resp.StatusCode)
if resp.StatusCode != http.StatusForbidden {
t.Log("actual: " + strconv.Itoa(resp.StatusCode))
t.Log("expected: " + strconv.Itoa(http.StatusForbidden))
t.Fatal("wrong status code")
}
}
func CheckUnauthorizedStatus(t *testing.T, resp *model.Response) {
t.Helper()
if resp.Error == nil {
t.Fatal("should have errored with status:" + strconv.Itoa(http.StatusUnauthorized))
return
}
if resp.StatusCode != http.StatusUnauthorized {
t.Log("actual: " + strconv.Itoa(resp.StatusCode))
t.Log("expected: " + strconv.Itoa(http.StatusUnauthorized))
t.Fatal("wrong status code")
}
}
func CheckNotFoundStatus(t *testing.T, resp *model.Response) {
t.Helper()
if resp.Error == nil {
t.Fatal("should have errored with status:" + strconv.Itoa(http.StatusNotFound))
return
}
if resp.StatusCode != http.StatusNotFound {
t.Log("actual: " + strconv.Itoa(resp.StatusCode))
t.Log("expected: " + strconv.Itoa(http.StatusNotFound))
t.Fatal("wrong status code")
}
}
func CheckBadRequestStatus(t *testing.T, resp *model.Response) {
t.Helper()
if resp.Error == nil {
t.Fatal("should have errored with status:" + strconv.Itoa(http.StatusBadRequest))
return
}
if resp.StatusCode != http.StatusBadRequest {
t.Log("actual: " + strconv.Itoa(resp.StatusCode))
t.Log("expected: " + strconv.Itoa(http.StatusBadRequest))
t.Fatal("wrong status code")
}
}
func CheckNotImplementedStatus(t *testing.T, resp *model.Response) {
t.Helper()
if resp.Error == nil {
t.Fatal("should have errored with status:" + strconv.Itoa(http.StatusNotImplemented))
return
}
if resp.StatusCode != http.StatusNotImplemented {
t.Log("actual: " + strconv.Itoa(resp.StatusCode))
t.Log("expected: " + strconv.Itoa(http.StatusNotImplemented))
t.Fatal("wrong status code")
case resp.StatusCode != expectedStatus:
t.Fatalf("Expected http status:%v, got %v (err: %q)", expectedStatus, resp.StatusCode, resp.Error)
}
}
func CheckOKStatus(t *testing.T, resp *model.Response) {
t.Helper()
checkHTTPStatus(t, resp, http.StatusOK, false)
}
CheckNoError(t, resp)
func CheckCreatedStatus(t *testing.T, resp *model.Response) {
t.Helper()
checkHTTPStatus(t, resp, http.StatusCreated, false)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("wrong status code. expected %d got %d", http.StatusOK, resp.StatusCode)
}
func CheckForbiddenStatus(t *testing.T, resp *model.Response) {
t.Helper()
checkHTTPStatus(t, resp, http.StatusForbidden, true)
}
func CheckUnauthorizedStatus(t *testing.T, resp *model.Response) {
t.Helper()
checkHTTPStatus(t, resp, http.StatusUnauthorized, true)
}
func CheckNotFoundStatus(t *testing.T, resp *model.Response) {
t.Helper()
checkHTTPStatus(t, resp, http.StatusNotFound, true)
}
func CheckBadRequestStatus(t *testing.T, resp *model.Response) {
t.Helper()
checkHTTPStatus(t, resp, http.StatusBadRequest, true)
}
func CheckNotImplementedStatus(t *testing.T, resp *model.Response) {
t.Helper()
checkHTTPStatus(t, resp, http.StatusNotImplemented, true)
}
func CheckRequestEntityTooLargeStatus(t *testing.T, resp *model.Response) {
t.Helper()
checkHTTPStatus(t, resp, http.StatusRequestEntityTooLarge, true)
}
func CheckInternalErrorStatus(t *testing.T, resp *model.Response) {
t.Helper()
checkHTTPStatus(t, resp, http.StatusInternalServerError, true)
}
func CheckErrorMessage(t *testing.T, resp *model.Response, errorId string) {
@@ -687,21 +655,6 @@ func CheckErrorMessage(t *testing.T, resp *model.Response, errorId string) {
}
}
func CheckInternalErrorStatus(t *testing.T, resp *model.Response) {
t.Helper()
if resp.Error == nil {
t.Fatal("should have errored with status:" + strconv.Itoa(http.StatusInternalServerError))
return
}
if resp.StatusCode != http.StatusInternalServerError {
t.Log("actual: " + strconv.Itoa(resp.StatusCode))
t.Log("expected: " + strconv.Itoa(http.StatusInternalServerError))
t.Fatal("wrong status code")
}
}
// Similar to s3.New() but allows initialization of signature v2 or signature v4 client.
// If signV2 input is false, function always returns signature v4.
//

View File

@@ -4,9 +4,12 @@
package api4
import (
"bytes"
"crypto/subtle"
"io"
"io/ioutil"
"mime"
"mime/multipart"
"net/http"
"net/url"
"strconv"
@@ -46,8 +49,11 @@ var MEDIA_CONTENT_TYPES = [...]string{
"audio/wav",
}
const maxUploadDrainBytes = (10 * 1024 * 1024) // 10Mb
const maxMultipartFormDataBytes = 10 * 1024 // 10Kb
func (api *API) InitFile() {
api.BaseRoutes.Files.Handle("", api.ApiSessionRequired(uploadFile)).Methods("POST")
api.BaseRoutes.Files.Handle("", api.ApiSessionRequired(uploadFileStream)).Methods("POST")
api.BaseRoutes.File.Handle("", api.ApiSessionRequiredTrustRequester(getFile)).Methods("GET")
api.BaseRoutes.File.Handle("/thumbnail", api.ApiSessionRequiredTrustRequester(getFileThumbnail)).Methods("GET")
api.BaseRoutes.File.Handle("/link", api.ApiSessionRequired(getFileLink)).Methods("GET")
@@ -114,8 +120,9 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
channelId := props["channel_id"][0]
if len(channelId) == 0 {
c.SetInvalidParam("channel_id")
c.Params.ChannelId = channelId
c.RequireChannelId()
if c.Err != nil {
return
}
@@ -143,6 +150,371 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) {
w.Write([]byte(resStruct.ToJson()))
}
func parseMultipartRequestHeader(req *http.Request) (boundary string, err error) {
v := req.Header.Get("Content-Type")
if v == "" {
return "", http.ErrNotMultipart
}
d, params, err := mime.ParseMediaType(v)
if err != nil || d != "multipart/form-data" {
return "", http.ErrNotMultipart
}
boundary, ok := params["boundary"]
if !ok {
return "", http.ErrMissingBoundary
}
return boundary, nil
}
func multipartReader(req *http.Request, stream io.Reader) (*multipart.Reader, error) {
boundary, err := parseMultipartRequestHeader(req)
if err != nil {
return nil, err
}
if stream != nil {
return multipart.NewReader(stream, boundary), nil
} else {
return multipart.NewReader(req.Body, boundary), nil
}
}
func uploadFileStream(c *Context, w http.ResponseWriter, r *http.Request) {
// Drain any remaining bytes in the request body, up to a limit
defer io.CopyN(ioutil.Discard, r.Body, maxUploadDrainBytes)
if !*c.App.Config().FileSettings.EnableFileAttachments {
c.Err = model.NewAppError("uploadFileStream",
"api.file.attachments.disabled.app_error",
nil, "", http.StatusNotImplemented)
return
}
// Parse the post as a regular form (in practice, use the URL values
// since we never expect a real application/x-www-form-urlencoded
// form).
if r.Form == nil {
err := r.ParseForm()
if err != nil {
c.Err = model.NewAppError("uploadFileStream",
"api.file.upload_file.read_request.app_error",
nil, err.Error(), http.StatusBadRequest)
return
}
}
timestamp := time.Now()
var fileUploadResponse *model.FileUploadResponse
_, err := parseMultipartRequestHeader(r)
switch err {
case nil:
fileUploadResponse = uploadFileMultipart(c, r, nil, timestamp)
case http.ErrNotMultipart:
fileUploadResponse = uploadFileSimple(c, r, timestamp)
default:
c.Err = model.NewAppError("uploadFileStream",
"api.file.upload_file.read_request.app_error",
nil, err.Error(), http.StatusBadRequest)
}
if c.Err != nil {
return
}
// Write the response values to the output upon return
w.WriteHeader(http.StatusCreated)
w.Write([]byte(fileUploadResponse.ToJson()))
}
// uploadFileSimple uploads a file from a simple POST with the file in the request body
func uploadFileSimple(c *Context, r *http.Request, timestamp time.Time) *model.FileUploadResponse {
// Simple POST with the file in the body and all metadata in the args.
c.RequireChannelId()
c.RequireFilename()
if c.Err != nil {
return nil
}
if !c.App.SessionHasPermissionToChannel(c.App.Session, c.Params.ChannelId, model.PERMISSION_UPLOAD_FILE) {
c.SetPermissionError(model.PERMISSION_UPLOAD_FILE)
return nil
}
clientId := r.Form.Get("client_id")
info, appErr := c.App.UploadFileX(c.Params.ChannelId, c.Params.Filename, r.Body,
app.UploadFileSetTeamId(FILE_TEAM_ID),
app.UploadFileSetUserId(c.App.Session.UserId),
app.UploadFileSetTimestamp(timestamp),
app.UploadFileSetContentLength(r.ContentLength),
app.UploadFileSetClientId(clientId))
if appErr != nil {
c.Err = appErr
return nil
}
fileUploadResponse := &model.FileUploadResponse{
FileInfos: []*model.FileInfo{info},
}
if clientId != "" {
fileUploadResponse.ClientIds = []string{clientId}
}
return fileUploadResponse
}
// uploadFileMultipart parses and uploads file(s) from a mime/multipart
// request. It pre-buffers up to the first part which is either the (a)
// `channel_id` value, or (b) a file. Then in case of (a) it re-processes the
// entire message recursively calling itself in stream mode. In case of (b) it
// calls to uploadFileMultipartLegacy for legacy support
func uploadFileMultipart(c *Context, r *http.Request, asStream io.Reader, timestamp time.Time) *model.FileUploadResponse {
expectClientIds := true
var clientIds []string
resp := model.FileUploadResponse{
FileInfos: []*model.FileInfo{},
ClientIds: []string{},
}
var buf *bytes.Buffer
var mr *multipart.Reader
var err error
if asStream == nil {
// We need to buffer until we get the channel_id, or the first file.
buf = &bytes.Buffer{}
mr, err = multipartReader(r, io.TeeReader(r.Body, buf))
} else {
mr, err = multipartReader(r, asStream)
}
if err != nil {
c.Err = model.NewAppError("uploadFileMultipart",
"api.file.upload_file.read_request.app_error",
nil, err.Error(), http.StatusBadRequest)
return nil
}
nFiles := 0
NEXT_PART:
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
c.Err = model.NewAppError("uploadFileMultipart",
"api.file.upload_file.read_request.app_error",
nil, err.Error(), http.StatusBadRequest)
return nil
}
// Parse any form fields in the multipart.
formname := part.FormName()
if formname == "" {
continue
}
filename := part.FileName()
if filename == "" {
var b bytes.Buffer
_, err := io.CopyN(&b, part, maxMultipartFormDataBytes)
if err != nil && err != io.EOF {
c.Err = model.NewAppError("uploadFileMultipart",
"api.file.upload_file.read_form_value.app_error",
map[string]interface{}{"Formname": formname},
err.Error(), http.StatusBadRequest)
return nil
}
v := b.String()
switch formname {
case "channel_id":
if c.Params.ChannelId != "" && c.Params.ChannelId != v {
c.Err = model.NewAppError("uploadFileMultipart",
"api.file.upload_file.multiple_channel_ids.app_error",
nil, "", http.StatusBadRequest)
return nil
}
if v != "" {
c.Params.ChannelId = v
}
// Got channel_id, re-process the entire post
// in the streaming mode.
if asStream == nil {
return uploadFileMultipart(c, r, io.MultiReader(buf, r.Body), timestamp)
}
case "client_ids":
if !expectClientIds {
c.SetInvalidParam("client_ids")
return nil
}
clientIds = append(clientIds, v)
default:
c.SetInvalidParam(formname)
return nil
}
continue NEXT_PART
}
// A file part.
if c.Params.ChannelId == "" && asStream == nil {
// Got file before channel_id, fall back to legacy buffered mode
mr, err = multipartReader(r, io.MultiReader(buf, r.Body))
if err != nil {
c.Err = model.NewAppError("uploadFileMultipart",
"api.file.upload_file.read_request.app_error",
nil, err.Error(), http.StatusBadRequest)
return nil
}
return uploadFileMultipartLegacy(c, mr, timestamp)
}
c.RequireChannelId()
if c.Err != nil {
return nil
}
if !c.App.SessionHasPermissionToChannel(c.App.Session, c.Params.ChannelId, model.PERMISSION_UPLOAD_FILE) {
c.SetPermissionError(model.PERMISSION_UPLOAD_FILE)
return nil
}
// If there's no clientIds when the first file comes, expect
// none later.
if nFiles == 0 && len(clientIds) == 0 {
expectClientIds = false
}
// Must have a exactly one client ID for each file.
clientId := ""
if expectClientIds {
if nFiles >= len(clientIds) {
c.SetInvalidParam("client_ids")
return nil
}
clientId = clientIds[nFiles]
}
info, appErr := c.App.UploadFileX(c.Params.ChannelId, filename, part,
app.UploadFileSetTeamId(FILE_TEAM_ID),
app.UploadFileSetUserId(c.App.Session.UserId),
app.UploadFileSetTimestamp(timestamp),
app.UploadFileSetContentLength(-1),
app.UploadFileSetClientId(clientId))
if appErr != nil {
c.Err = appErr
return nil
}
// add to the response
resp.FileInfos = append(resp.FileInfos, info)
if expectClientIds {
resp.ClientIds = append(resp.ClientIds, clientId)
}
nFiles++
}
// Verify that the number of ClientIds matched the number of files.
if expectClientIds && len(clientIds) != nFiles {
c.Err = model.NewAppError("uploadFileMultipart",
"api.file.upload_file.incorrect_number_of_client_ids.app_error",
map[string]interface{}{"NumClientIds": len(clientIds), "NumFiles": nFiles},
"", http.StatusBadRequest)
return nil
}
return &resp
}
// uploadFileMultipartLegacy reads, buffers, and then uploads the message,
// borrowing from http.ParseMultipartForm. If successful it returns a
// *model.FileUploadResponse filled in with the individual model.FileInfo's.
func uploadFileMultipartLegacy(c *Context, mr *multipart.Reader,
timestamp time.Time) *model.FileUploadResponse {
// Parse the entire form.
form, err := mr.ReadForm(*c.App.Config().FileSettings.MaxFileSize)
if err != nil {
c.Err = model.NewAppError("uploadFileMultipartLegacy",
"api.file.upload_file.read_request.app_error",
nil, err.Error(), http.StatusInternalServerError)
return nil
}
// get and validate the channel Id, permission to upload there.
if len(form.Value["channel_id"]) == 0 {
c.SetInvalidParam("channel_id")
return nil
}
channelId := form.Value["channel_id"][0]
c.Params.ChannelId = channelId
c.RequireChannelId()
if c.Err != nil {
return nil
}
if !c.App.SessionHasPermissionToChannel(c.App.Session, channelId, model.PERMISSION_UPLOAD_FILE) {
c.SetPermissionError(model.PERMISSION_UPLOAD_FILE)
return nil
}
// Check that we have either no client IDs, or one per file.
clientIds := form.Value["client_ids"]
fileHeaders := form.File["files"]
if len(clientIds) != 0 && len(clientIds) != len(fileHeaders) {
c.Err = model.NewAppError("uploadFilesMultipartBuffered",
"api.file.upload_file.incorrect_number_of_client_ids.app_error",
map[string]interface{}{"NumClientIds": len(clientIds), "NumFiles": len(fileHeaders)},
"", http.StatusBadRequest)
return nil
}
resp := model.FileUploadResponse{
FileInfos: []*model.FileInfo{},
ClientIds: []string{},
}
for i, fileHeader := range fileHeaders {
f, err := fileHeader.Open()
if err != nil {
c.Err = model.NewAppError("uploadFileMultipartLegacy",
"api.file.upload_file.read_request.app_error",
nil, err.Error(), http.StatusBadRequest)
return nil
}
clientId := ""
if len(clientIds) > 0 {
clientId = clientIds[i]
}
info, appErr := c.App.UploadFileX(c.Params.ChannelId, fileHeader.Filename, f,
app.UploadFileSetTeamId(FILE_TEAM_ID),
app.UploadFileSetUserId(c.App.Session.UserId),
app.UploadFileSetTimestamp(timestamp),
app.UploadFileSetContentLength(-1),
app.UploadFileSetClientId(clientId))
f.Close()
if appErr != nil {
c.Err = appErr
return nil
}
resp.FileInfos = append(resp.FileInfos, info)
if clientId != "" {
resp.ClientIds = append(resp.ClientIds, clientId)
}
}
return &resp
}
func getFile(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireFileId()
if c.Err != nil {

View File

@@ -4,250 +4,752 @@
package api4
import (
"bytes"
"fmt"
"io"
"io/ioutil"
"mime/multipart"
"net/http"
"net/textproto"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/mattermost/mattermost-server/app"
"github.com/mattermost/mattermost-server/model"
"github.com/mattermost/mattermost-server/store"
"github.com/mattermost/mattermost-server/utils"
"github.com/mattermost/mattermost-server/utils/testutils"
)
func TestUploadFileAsMultipart(t *testing.T) {
th := Setup().InitBasic()
defer th.TearDown()
Client := th.Client
var testDir = ""
user := th.BasicUser
channel := th.BasicChannel
func init() {
testDir, _ = utils.FindDir("tests")
}
var uploadInfo *model.FileInfo
var data []byte
var err error
if data, err = testutils.ReadTestFile("test.png"); err != nil {
t.Fatal(err)
} else if fileResp, resp := Client.UploadFile(data, channel.Id, "test.png"); resp.Error != nil {
t.Fatal(resp.Error)
} else if len(fileResp.FileInfos) != 1 {
t.Fatal("should've returned a single file infos")
} else {
uploadInfo = fileResp.FileInfos[0]
}
// The returned file info from the upload call will be missing some fields that will be stored in the database
if uploadInfo.CreatorId != user.Id {
t.Fatal("file should be assigned to user")
} else if uploadInfo.PostId != "" {
t.Fatal("file shouldn't have a post")
} else if uploadInfo.Path != "" {
t.Fatal("file path should not be set on returned info")
} else if uploadInfo.ThumbnailPath != "" {
t.Fatal("file thumbnail path should not be set on returned info")
} else if uploadInfo.PreviewPath != "" {
t.Fatal("file preview path should not be set on returned info")
}
var info *model.FileInfo
if result := <-th.App.Srv.Store.FileInfo().Get(uploadInfo.Id); result.Err != nil {
t.Fatal(result.Err)
} else {
info = result.Data.(*model.FileInfo)
}
if info.Id != uploadInfo.Id {
t.Fatal("file id from response should match one stored in database")
} else if info.CreatorId != user.Id {
t.Fatal("file should be assigned to user")
} else if info.PostId != "" {
t.Fatal("file shouldn't have a post")
} else if info.Path == "" {
t.Fatal("file path should be set in database")
} else if info.ThumbnailPath == "" {
t.Fatal("file thumbnail path should be set in database")
} else if info.PreviewPath == "" {
t.Fatal("file preview path should be set in database")
}
date := time.Now().Format("20060102")
// This also makes sure that the relative path provided above is sanitized out
expectedPath := fmt.Sprintf("%v/teams/%v/channels/%v/users/%v/%v/test.png", date, FILE_TEAM_ID, channel.Id, user.Id, info.Id)
if info.Path != expectedPath {
t.Logf("file is saved in %v", info.Path)
t.Fatalf("file should've been saved in %v", expectedPath)
}
expectedThumbnailPath := fmt.Sprintf("%v/teams/%v/channels/%v/users/%v/%v/test_thumb.jpg", date, FILE_TEAM_ID, channel.Id, user.Id, info.Id)
if info.ThumbnailPath != expectedThumbnailPath {
t.Logf("file thumbnail is saved in %v", info.ThumbnailPath)
t.Fatalf("file thumbnail should've been saved in %v", expectedThumbnailPath)
}
expectedPreviewPath := fmt.Sprintf("%v/teams/%v/channels/%v/users/%v/%v/test_preview.jpg", date, FILE_TEAM_ID, channel.Id, user.Id, info.Id)
if info.PreviewPath != expectedPreviewPath {
t.Logf("file preview is saved in %v", info.PreviewPath)
t.Fatalf("file preview should've been saved in %v", expectedPreviewPath)
}
// Wait a bit for files to ready
time.Sleep(2 * time.Second)
if err := th.cleanupTestFile(info); err != nil {
t.Fatal(err)
}
_, resp := Client.UploadFile(data, model.NewId(), "test.png")
CheckForbiddenStatus(t, resp)
_, resp = Client.UploadFile(data, "../../junk", "test.png")
CheckForbiddenStatus(t, resp)
_, resp = th.SystemAdminClient.UploadFile(data, model.NewId(), "test.png")
CheckForbiddenStatus(t, resp)
_, resp = th.SystemAdminClient.UploadFile(data, "../../junk", "test.png")
CheckForbiddenStatus(t, resp)
_, resp = th.SystemAdminClient.UploadFile(data, channel.Id, "test.png")
CheckNoError(t, resp)
enableFileAttachments := *th.App.Config().FileSettings.EnableFileAttachments
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.EnableFileAttachments = enableFileAttachments })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.EnableFileAttachments = false })
_, resp = th.SystemAdminClient.UploadFile(data, channel.Id, "test.png")
if resp.StatusCode == 0 {
t.Log("file upload request failed completely")
} else if resp.StatusCode != http.StatusNotImplemented {
// This should return an HTTP 501, but it occasionally causes the http client itself to error
t.Fatalf("should've returned HTTP 501 or failed completely, got %v instead", resp.StatusCode)
func checkCond(tb testing.TB, cond bool, text string) {
if !cond {
tb.Error(text)
}
}
func TestUploadFileAsRequestBody(t *testing.T) {
func checkEq(tb testing.TB, v1, v2 interface{}, text string) {
checkCond(tb, fmt.Sprintf("%+v", v1) == fmt.Sprintf("%+v", v2), text)
}
func checkNeq(tb testing.TB, v1, v2 interface{}, text string) {
checkCond(tb, fmt.Sprintf("%+v", v1) != fmt.Sprintf("%+v", v2), text)
}
type zeroReader struct {
limit, read int
}
func (z *zeroReader) Read(b []byte) (int, error) {
for i := range b {
if z.read == z.limit {
return i, io.EOF
}
b[i] = 0
z.read++
}
return len(b), nil
}
// File Section
var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"")
func escapeQuotes(s string) string {
return quoteEscaper.Replace(s)
}
type UploadOpener func() (io.ReadCloser, int64, error)
func NewUploadOpenerReader(in io.Reader) UploadOpener {
return func() (io.ReadCloser, int64, error) {
rc, ok := in.(io.ReadCloser)
if ok {
return rc, -1, nil
} else {
return ioutil.NopCloser(in), -1, nil
}
}
}
func NewUploadOpenerFile(path string) UploadOpener {
return func() (io.ReadCloser, int64, error) {
fi, err := os.Stat(path)
if err != nil {
return nil, 0, err
}
f, err := os.Open(path)
if err != nil {
return nil, 0, err
}
return f, fi.Size(), nil
}
}
// testUploadFile and testUploadFiles have been "staged" here, eventually they
// should move back to being model.Client4 methods, once the specifics of the
// public API are sorted out.
func testUploadFile(c *model.Client4, url string, body io.Reader, contentType string,
contentLength int64) (*model.FileUploadResponse, *model.Response) {
rq, _ := http.NewRequest("POST", c.ApiUrl+c.GetFilesRoute()+url, body)
if contentLength != 0 {
rq.ContentLength = contentLength
}
rq.Header.Set("Content-Type", contentType)
if len(c.AuthToken) > 0 {
rq.Header.Set(model.HEADER_AUTH, c.AuthType+" "+c.AuthToken)
}
rp, err := c.HttpClient.Do(rq)
if err != nil || rp == nil {
return nil, model.BuildErrorResponse(rp, model.NewAppError(url, "model.client.connecting.app_error", nil, err.Error(), 0))
}
defer closeBody(rp)
if rp.StatusCode >= 300 {
return nil, model.BuildErrorResponse(rp, model.AppErrorFromJson(rp.Body))
}
return model.FileUploadResponseFromJson(rp.Body), model.BuildResponse(rp)
}
func testUploadFiles(
c *model.Client4,
channelId string,
names []string,
openers []UploadOpener,
contentLengths []int64,
clientIds []string,
useMultipart,
useChunkedInSimplePost bool,
) (
fileUploadResponse *model.FileUploadResponse,
response *model.Response,
) {
// Do not check len(clientIds), leave it entirely to the user to
// provide. The server will error out if it does not match the number
// of files, but it's not critical here.
if len(names) == 0 || len(openers) == 0 || len(names) != len(openers) {
return nil, &model.Response{
Error: model.NewAppError("testUploadFiles",
"model.client.upload_post_attachment.file.app_error",
nil, "Empty or mismatched file data", http.StatusBadRequest),
}
}
// emergencyResponse is a convenience wrapper to return an error response
emergencyResponse := func(err error, errCode string) *model.Response {
return &model.Response{
Error: model.NewAppError("testUploadFiles",
"model.client."+errCode+".app_error",
nil, err.Error(), http.StatusBadRequest),
}
}
// For multipart, start writing the request as a goroutine, and pipe
// multipart.Writer into it, otherwise generate a new request each
// time.
pipeReader, pipeWriter := io.Pipe()
mw := multipart.NewWriter(pipeWriter)
if useMultipart {
fileUploadResponseChannel := make(chan *model.FileUploadResponse)
responseChannel := make(chan *model.Response)
closedMultipart := false
go func() {
fur, resp := testUploadFile(c, "", pipeReader, mw.FormDataContentType(), -1)
responseChannel <- resp
fileUploadResponseChannel <- fur
}()
defer func() {
for {
select {
// Premature response, before the entire
// multipart was sent
case response = <-responseChannel:
// Guaranteed to be there
fileUploadResponse = <-fileUploadResponseChannel
if !closedMultipart {
_ = mw.Close()
_ = pipeWriter.Close()
closedMultipart = true
}
return
// Normal response, after the multipart was sent.
default:
if !closedMultipart {
err := mw.Close()
if err != nil {
fileUploadResponse = nil
response = emergencyResponse(err, "upload_post_attachment.writer")
return
}
err = pipeWriter.Close()
if err != nil {
fileUploadResponse = nil
response = emergencyResponse(err, "upload_post_attachment.writer")
return
}
closedMultipart = true
}
}
}
}()
err := mw.WriteField("channel_id", channelId)
if err != nil {
return nil, emergencyResponse(err, "upload_post_attachment.channel_id")
}
} else {
fileUploadResponse = &model.FileUploadResponse{}
}
var f io.ReadCloser
var cl int64
var err error
data := make([]byte, 512)
upload := func(i int, f io.ReadCloser) *model.Response {
defer f.Close()
if len(contentLengths) > i {
cl = contentLengths[i]
}
n, err := f.Read(data)
if err != nil && err != io.EOF {
return emergencyResponse(err, "upload_post_attachment")
}
ct := http.DetectContentType(data[:n])
reader := io.MultiReader(bytes.NewReader(data[:n]), f)
if useMultipart {
if len(clientIds) > i {
err := mw.WriteField("client_ids", clientIds[i])
if err != nil {
return emergencyResponse(err, "upload_post_attachment.file")
}
}
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition",
fmt.Sprintf(`form-data; name="files"; filename="%s"`, escapeQuotes(names[i])))
h.Set("Content-Type", ct)
// If we error here, writing to mw, the deferred handler
part, err := mw.CreatePart(h)
if err != nil {
return emergencyResponse(err, "upload_post_attachment.writer")
}
_, err = io.Copy(part, reader)
if err != nil {
return emergencyResponse(err, "upload_post_attachment.writer")
}
} else {
postURL := fmt.Sprintf("?channel_id=%v", url.QueryEscape(channelId)) +
fmt.Sprintf("&filename=%v", url.QueryEscape(names[i]))
if len(clientIds) > i {
postURL += fmt.Sprintf("&client_id=%v", url.QueryEscape(clientIds[i]))
}
if useChunkedInSimplePost {
cl = -1
}
fur, resp := testUploadFile(c, postURL, reader, ct, cl)
if resp.Error != nil {
return resp
}
fileUploadResponse.FileInfos = append(fileUploadResponse.FileInfos, fur.FileInfos[0])
if len(clientIds) > 0 {
if len(fur.ClientIds) > 0 {
fileUploadResponse.ClientIds = append(fileUploadResponse.ClientIds, fur.ClientIds[0])
} else {
fileUploadResponse.ClientIds = append(fileUploadResponse.ClientIds, "")
}
}
response = resp
}
return nil
}
for i, open := range openers {
f, cl, err = open()
if err != nil {
return nil, emergencyResponse(err, "upload_post_attachment")
}
resp := upload(i, f)
if resp != nil && resp.Error != nil {
return nil, resp
}
}
// In case of a simple POST, the return values have been set by upload(),
// otherwise we finished writing the multipart, and the return values will
// be set in defer
return fileUploadResponse, response
}
func TestUploadFiles(t *testing.T) {
th := Setup().InitBasic()
defer th.TearDown()
Client := th.Client
if *th.App.Config().FileSettings.DriverName == "" {
t.Skip("skipping because no file driver is enabled")
}
user := th.BasicUser
channel := th.BasicChannel
var uploadInfo *model.FileInfo
var data []byte
var err error
if data, err = testutils.ReadTestFile("test.png"); err != nil {
t.Fatal(err)
} else if fileResp, resp := Client.UploadFileAsRequestBody(data, channel.Id, "test.png"); resp.Error != nil {
t.Fatal(resp.Error)
} else if len(fileResp.FileInfos) != 1 {
t.Fatal("should've returned a single file infos")
} else {
uploadInfo = fileResp.FileInfos[0]
}
// The returned file info from the upload call will be missing some fields that will be stored in the database
if uploadInfo.CreatorId != user.Id {
t.Fatal("file should be assigned to user")
} else if uploadInfo.PostId != "" {
t.Fatal("file shouldn't have a post")
} else if uploadInfo.Path != "" {
t.Fatal("file path should not be set on returned info")
} else if uploadInfo.ThumbnailPath != "" {
t.Fatal("file thumbnail path should not be set on returned info")
} else if uploadInfo.PreviewPath != "" {
t.Fatal("file preview path should not be set on returned info")
}
var info *model.FileInfo
if result := <-th.App.Srv.Store.FileInfo().Get(uploadInfo.Id); result.Err != nil {
t.Fatal(result.Err)
} else {
info = result.Data.(*model.FileInfo)
}
if info.Id != uploadInfo.Id {
t.Fatal("file id from response should match one stored in database")
} else if info.CreatorId != user.Id {
t.Fatal("file should be assigned to user")
} else if info.PostId != "" {
t.Fatal("file shouldn't have a post")
} else if info.Path == "" {
t.Fatal("file path should be set in database")
} else if info.ThumbnailPath == "" {
t.Fatal("file thumbnail path should be set in database")
} else if info.PreviewPath == "" {
t.Fatal("file preview path should be set in database")
}
date := time.Now().Format("20060102")
// This also makes sure that the relative path provided above is sanitized out
expectedPath := fmt.Sprintf("%v/teams/%v/channels/%v/users/%v/%v/test.png", date, FILE_TEAM_ID, channel.Id, user.Id, info.Id)
if info.Path != expectedPath {
t.Logf("file is saved in %v", info.Path)
t.Fatalf("file should've been saved in %v", expectedPath)
// Get better error messages
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableDeveloper = true })
op := func(name string) UploadOpener {
return NewUploadOpenerFile(filepath.Join(testDir, name))
}
expectedThumbnailPath := fmt.Sprintf("%v/teams/%v/channels/%v/users/%v/%v/test_thumb.jpg", date, FILE_TEAM_ID, channel.Id, user.Id, info.Id)
if info.ThumbnailPath != expectedThumbnailPath {
t.Logf("file thumbnail is saved in %v", info.ThumbnailPath)
t.Fatalf("file thumbnail should've been saved in %v", expectedThumbnailPath)
tests := []struct {
title string
client *model.Client4
openers []UploadOpener
names []string
clientIds []string
skipSuccessValidation bool
skipPayloadValidation bool
skipSimplePost bool
skipMultipart bool
channelId string
useChunkedInSimplePost bool
expectedCreatorId string
expectedPayloadNames []string
expectImage bool
expectedImageWidths []int
expectedImageHeights []int
expectedImageThumbnailNames []string
expectedImagePreviewNames []string
expectedImageHasPreview []bool
setupConfig func(a *app.App) func(a *app.App)
checkResponse func(t *testing.T, resp *model.Response)
}{
// Upload a bunch of files, mixed images and non-images
{
title: "Happy",
names: []string{"test.png", "testgif.gif", "testplugin.tar.gz", "test-search.md"},
expectedCreatorId: th.BasicUser.Id,
},
// Upload a bunch of files, with clientIds
{
title: "Happy client_ids",
names: []string{"test.png", "testgif.gif", "testplugin.tar.gz", "test-search.md"},
clientIds: []string{"1", "2", "3", "4"},
expectedCreatorId: th.BasicUser.Id,
},
// Upload a bunch of images. testgif.gif is an animated GIF,
// so it does not have HasPreviewImage set.
{
title: "Happy images",
names: []string{"test.png", "testgif.gif"},
expectImage: true,
expectedCreatorId: th.BasicUser.Id,
expectedImageWidths: []int{408, 118},
expectedImageHeights: []int{336, 118},
expectedImageHasPreview: []bool{true, false},
},
{
title: "Happy invalid image",
names: []string{"testgif.gif"},
openers: []UploadOpener{NewUploadOpenerFile(filepath.Join(testDir, "test-search.md"))},
skipPayloadValidation: true,
expectedCreatorId: th.BasicUser.Id,
},
// Simple POST, chunked encoding
{
title: "Happy image chunked post",
skipMultipart: true,
useChunkedInSimplePost: true,
names: []string{"test.png"},
expectImage: true,
expectedImageWidths: []int{408},
expectedImageHeights: []int{336},
expectedImageHasPreview: []bool{true},
expectedCreatorId: th.BasicUser.Id,
},
// Image thumbnail and preview: size and orientation. Note that
// the expected image dimensions remain the same regardless of the
// orientation - what we save in FileInfo is used by the
// clients to size UI elements, so the dimensions are "actual".
{
title: "Happy image thumbnail/preview 1",
names: []string{"orientation_test_1.jpeg"},
expectedImageThumbnailNames: []string{"orientation_test_1_expected_thumb.jpeg"},
expectedImagePreviewNames: []string{"orientation_test_1_expected_preview.jpeg"},
expectImage: true,
expectedImageWidths: []int{2860},
expectedImageHeights: []int{1578},
expectedImageHasPreview: []bool{true},
expectedCreatorId: th.BasicUser.Id,
},
{
title: "Happy image thumbnail/preview 2",
names: []string{"orientation_test_2.jpeg"},
expectedImageThumbnailNames: []string{"orientation_test_2_expected_thumb.jpeg"},
expectedImagePreviewNames: []string{"orientation_test_2_expected_preview.jpeg"},
expectImage: true,
expectedImageWidths: []int{2860},
expectedImageHeights: []int{1578},
expectedImageHasPreview: []bool{true},
expectedCreatorId: th.BasicUser.Id,
},
{
title: "Happy image thumbnail/preview 3",
names: []string{"orientation_test_3.jpeg"},
expectedImageThumbnailNames: []string{"orientation_test_3_expected_thumb.jpeg"},
expectedImagePreviewNames: []string{"orientation_test_3_expected_preview.jpeg"},
expectImage: true,
expectedImageWidths: []int{2860},
expectedImageHeights: []int{1578},
expectedImageHasPreview: []bool{true},
expectedCreatorId: th.BasicUser.Id,
},
{
title: "Happy image thumbnail/preview 4",
names: []string{"orientation_test_4.jpeg"},
expectedImageThumbnailNames: []string{"orientation_test_4_expected_thumb.jpeg"},
expectedImagePreviewNames: []string{"orientation_test_4_expected_preview.jpeg"},
expectImage: true,
expectedImageWidths: []int{2860},
expectedImageHeights: []int{1578},
expectedImageHasPreview: []bool{true},
expectedCreatorId: th.BasicUser.Id,
},
{
title: "Happy image thumbnail/preview 5",
names: []string{"orientation_test_5.jpeg"},
expectedImageThumbnailNames: []string{"orientation_test_5_expected_thumb.jpeg"},
expectedImagePreviewNames: []string{"orientation_test_5_expected_preview.jpeg"},
expectImage: true,
expectedImageWidths: []int{2860},
expectedImageHeights: []int{1578},
expectedImageHasPreview: []bool{true},
expectedCreatorId: th.BasicUser.Id,
},
{
title: "Happy image thumbnail/preview 6",
names: []string{"orientation_test_6.jpeg"},
expectedImageThumbnailNames: []string{"orientation_test_6_expected_thumb.jpeg"},
expectedImagePreviewNames: []string{"orientation_test_6_expected_preview.jpeg"},
expectImage: true,
expectedImageWidths: []int{2860},
expectedImageHeights: []int{1578},
expectedImageHasPreview: []bool{true},
expectedCreatorId: th.BasicUser.Id,
},
{
title: "Happy image thumbnail/preview 7",
names: []string{"orientation_test_7.jpeg"},
expectedImageThumbnailNames: []string{"orientation_test_7_expected_thumb.jpeg"},
expectedImagePreviewNames: []string{"orientation_test_7_expected_preview.jpeg"},
expectImage: true,
expectedImageWidths: []int{2860},
expectedImageHeights: []int{1578},
expectedImageHasPreview: []bool{true},
expectedCreatorId: th.BasicUser.Id,
},
{
title: "Happy image thumbnail/preview 8",
names: []string{"orientation_test_8.jpeg"},
expectedImageThumbnailNames: []string{"orientation_test_8_expected_thumb.jpeg"},
expectedImagePreviewNames: []string{"orientation_test_8_expected_preview.jpeg"},
expectImage: true,
expectedImageWidths: []int{2860},
expectedImageHeights: []int{1578},
expectedImageHasPreview: []bool{true},
expectedCreatorId: th.BasicUser.Id,
},
{
title: "Happy admin",
client: th.SystemAdminClient,
names: []string{"test.png"},
expectedCreatorId: th.SystemAdminUser.Id,
},
{
title: "Happy stream",
useChunkedInSimplePost: true,
skipPayloadValidation: true,
names: []string{"50Mb-stream"},
openers: []UploadOpener{NewUploadOpenerReader(&zeroReader{limit: 50 * 1024 * 1024})},
setupConfig: func(a *app.App) func(a *app.App) {
maxFileSize := *a.Config().FileSettings.MaxFileSize
a.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.MaxFileSize = 50 * 1024 * 1024 })
return func(a *app.App) {
a.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.MaxFileSize = maxFileSize })
}
},
expectedCreatorId: th.BasicUser.Id,
},
// Error cases
{
title: "Error channel_id does not exist",
channelId: model.NewId(),
names: []string{"test.png"},
skipSuccessValidation: true,
checkResponse: CheckForbiddenStatus,
},
{
// on simple post this uploads the last file
// successfully, without a ClientId
title: "Error too few client_ids",
skipSimplePost: true,
names: []string{"test.png", "testplugin.tar.gz", "test-search.md"},
clientIds: []string{"1", "4"},
skipSuccessValidation: true,
checkResponse: CheckBadRequestStatus,
},
{
title: "Error invalid channel_id",
channelId: "../../junk",
names: []string{"test.png"},
skipSuccessValidation: true,
checkResponse: CheckBadRequestStatus,
},
{
title: "Error admin channel_id does not exist",
client: th.SystemAdminClient,
channelId: model.NewId(),
names: []string{"test.png"},
skipSuccessValidation: true,
checkResponse: CheckForbiddenStatus,
},
{
title: "Error admin invalid channel_id",
client: th.SystemAdminClient,
channelId: "../../junk",
names: []string{"test.png"},
skipSuccessValidation: true,
checkResponse: CheckBadRequestStatus,
},
{
title: "Error admin disabled uploads",
client: th.SystemAdminClient,
names: []string{"test.png"},
skipSuccessValidation: true,
checkResponse: CheckNotImplementedStatus,
setupConfig: func(a *app.App) func(a *app.App) {
enableFileAttachments := *a.Config().FileSettings.EnableFileAttachments
a.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.EnableFileAttachments = false })
return func(a *app.App) {
a.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.EnableFileAttachments = enableFileAttachments })
}
},
},
{
title: "Error file too large",
names: []string{"test.png"},
skipSuccessValidation: true,
checkResponse: CheckRequestEntityTooLargeStatus,
setupConfig: func(a *app.App) func(a *app.App) {
maxFileSize := *a.Config().FileSettings.MaxFileSize
a.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.MaxFileSize = 279590 })
return func(a *app.App) {
a.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.MaxFileSize = maxFileSize })
}
},
},
// File too large (chunked, simple POST only, multipart would've been redundant with above)
{
title: "File too large chunked",
useChunkedInSimplePost: true,
skipMultipart: true,
names: []string{"test.png"},
skipSuccessValidation: true,
checkResponse: CheckRequestEntityTooLargeStatus,
setupConfig: func(a *app.App) func(a *app.App) {
maxFileSize := *a.Config().FileSettings.MaxFileSize
a.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.MaxFileSize = 279590 })
return func(a *app.App) {
a.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.MaxFileSize = maxFileSize })
}
},
},
{
title: "Error stream too large",
skipPayloadValidation: true,
names: []string{"50Mb-stream"},
openers: []UploadOpener{NewUploadOpenerReader(&zeroReader{limit: 50 * 1024 * 1024})},
skipSuccessValidation: true,
checkResponse: CheckRequestEntityTooLargeStatus,
setupConfig: func(a *app.App) func(a *app.App) {
maxFileSize := *a.Config().FileSettings.MaxFileSize
a.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.MaxFileSize = 100 * 1024 })
return func(a *app.App) {
a.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.MaxFileSize = maxFileSize })
}
},
},
}
expectedPreviewPath := fmt.Sprintf("%v/teams/%v/channels/%v/users/%v/%v/test_preview.jpg", date, FILE_TEAM_ID, channel.Id, user.Id, info.Id)
if info.PreviewPath != expectedPreviewPath {
t.Logf("file preview is saved in %v", info.PreviewPath)
t.Fatalf("file preview should've been saved in %v", expectedPreviewPath)
}
for _, useMultipart := range []bool{true, false} {
for _, tc := range tests {
if tc.skipMultipart && useMultipart || tc.skipSimplePost && !useMultipart {
continue
}
// Wait a bit for files to ready
time.Sleep(2 * time.Second)
// Set the default values and title
client := th.Client
if tc.client != nil {
client = tc.client
}
channelId := channel.Id
if tc.channelId != "" {
channelId = tc.channelId
}
if tc.checkResponse == nil {
tc.checkResponse = CheckNoError
}
if err := th.cleanupTestFile(info); err != nil {
t.Fatal(err)
}
title := ""
if useMultipart {
title = "multipart "
} else {
title = "simple "
}
if tc.title != "" {
title += tc.title + " "
}
title += fmt.Sprintf("%v", tc.names)
_, resp := Client.UploadFileAsRequestBody(data, model.NewId(), "test.png")
CheckForbiddenStatus(t, resp)
// Apply any necessary config changes
var restoreConfig func(a *app.App)
if tc.setupConfig != nil {
restoreConfig = tc.setupConfig(th.App)
}
_, resp = Client.UploadFileAsRequestBody(data, "../../junk", "test.png")
if resp.StatusCode == 0 {
t.Log("file upload request failed completely")
} else if resp.StatusCode != http.StatusBadRequest {
// This should return an HTTP 400, but it occasionally causes the http client itself to error
t.Fatalf("should've returned HTTP 400 or failed completely, got %v instead", resp.StatusCode)
}
t.Run(title, func(t *testing.T) {
if len(tc.openers) == 0 {
for _, name := range tc.names {
tc.openers = append(tc.openers, op(name))
}
}
fileResp, resp := testUploadFiles(client, channelId, tc.names,
tc.openers, nil, tc.clientIds, useMultipart,
tc.useChunkedInSimplePost)
tc.checkResponse(t, resp)
if tc.skipSuccessValidation {
return
}
_, resp = th.SystemAdminClient.UploadFileAsRequestBody(data, model.NewId(), "test.png")
CheckForbiddenStatus(t, resp)
if fileResp == nil || len(fileResp.FileInfos) == 0 || len(fileResp.FileInfos) != len(tc.names) {
t.Fatal("Empty or mismatched actual or expected FileInfos")
}
_, resp = th.SystemAdminClient.UploadFileAsRequestBody(data, "../../junk", "test.png")
if resp.StatusCode == 0 {
t.Log("file upload request failed completely")
} else if resp.StatusCode != http.StatusBadRequest {
// This should return an HTTP 400, but it occasionally causes the http client itself to error
t.Fatalf("should've returned HTTP 400 or failed completely, got %v instead", resp.StatusCode)
}
for i, ri := range fileResp.FileInfos {
// The returned file info from the upload call will be missing some fields that will be stored in the database
checkEq(t, ri.CreatorId, tc.expectedCreatorId, "File should be assigned to user")
checkEq(t, ri.PostId, "", "File shouldn't have a post Id")
checkEq(t, ri.Path, "", "File path should not be set on returned info")
checkEq(t, ri.ThumbnailPath, "", "File thumbnail path should not be set on returned info")
checkEq(t, ri.PreviewPath, "", "File preview path should not be set on returned info")
if len(tc.clientIds) > i {
checkCond(t, len(fileResp.ClientIds) == len(tc.clientIds),
fmt.Sprintf("Wrong number of clientIds returned, expected %v, got %v", len(tc.clientIds), len(fileResp.ClientIds)))
checkEq(t, fileResp.ClientIds[i], tc.clientIds[i],
fmt.Sprintf("Wrong clientId returned, expected %v, got %v", tc.clientIds[i], fileResp.ClientIds[i]))
}
_, resp = th.SystemAdminClient.UploadFileAsRequestBody(data, channel.Id, "test.png")
CheckNoError(t, resp)
var dbInfo *model.FileInfo
result := <-th.App.Srv.Store.FileInfo().Get(ri.Id)
if result.Err != nil {
t.Error(result.Err)
} else {
dbInfo = result.Data.(*model.FileInfo)
}
checkEq(t, dbInfo.Id, ri.Id, "File id from response should match one stored in database")
checkEq(t, dbInfo.CreatorId, tc.expectedCreatorId, "F ile should be assigned to user")
checkEq(t, dbInfo.PostId, "", "File shouldn't have a post")
checkNeq(t, dbInfo.Path, "", "File path should be set in database")
_, fname := filepath.Split(dbInfo.Path)
ext := filepath.Ext(fname)
name := fname[:len(fname)-len(ext)]
expectedDir := fmt.Sprintf("%v/teams/%v/channels/%v/users/%s/%s", date, FILE_TEAM_ID, channel.Id, ri.CreatorId, ri.Id)
expectedPath := fmt.Sprintf("%s/%s", expectedDir, fname)
checkEq(t, dbInfo.Path, expectedPath,
fmt.Sprintf("File %v saved to:%q, expected:%q", dbInfo.Name, dbInfo.Path, expectedPath))
enableFileAttachments := *th.App.Config().FileSettings.EnableFileAttachments
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.EnableFileAttachments = enableFileAttachments })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.EnableFileAttachments = false })
if tc.expectImage {
expectedThumbnailPath := fmt.Sprintf("%s/%s_thumb.jpg", expectedDir, name)
expectedPreviewPath := fmt.Sprintf("%s/%s_preview.jpg", expectedDir, name)
checkEq(t, dbInfo.ThumbnailPath, expectedThumbnailPath,
fmt.Sprintf("Thumbnail for %v saved to:%q, expected:%q", dbInfo.Name, dbInfo.ThumbnailPath, expectedThumbnailPath))
checkEq(t, dbInfo.PreviewPath, expectedPreviewPath,
fmt.Sprintf("Preview for %v saved to:%q, expected:%q", dbInfo.Name, dbInfo.PreviewPath, expectedPreviewPath))
_, resp = th.SystemAdminClient.UploadFileAsRequestBody(data, channel.Id, "test.png")
if resp.StatusCode == 0 {
t.Log("file upload request failed completely")
} else if resp.StatusCode != http.StatusNotImplemented {
// This should return an HTTP 501, but it occasionally causes the http client itself to error
t.Fatalf("should've returned HTTP 501 or failed completely, got %v instead", resp.StatusCode)
checkCond(t,
dbInfo.HasPreviewImage == tc.expectedImageHasPreview[i],
fmt.Sprintf("Image: HasPreviewImage should be set for %s", dbInfo.Name))
checkCond(t,
dbInfo.Width == tc.expectedImageWidths[i] && dbInfo.Height == tc.expectedImageHeights[i],
fmt.Sprintf("Image dimensions: expected %dwx%dh, got %vwx%dh",
tc.expectedImageWidths[i], tc.expectedImageHeights[i],
dbInfo.Width, dbInfo.Height))
}
if !tc.skipPayloadValidation {
compare := func(get func(string) ([]byte, *model.Response), name string) {
data, resp := get(ri.Id)
if resp.Error != nil {
t.Fatal(resp.Error)
}
expected, err := ioutil.ReadFile(filepath.Join(testDir, name))
if err != nil {
t.Fatal(err)
}
if bytes.Compare(data, expected) != 0 {
tf, err := ioutil.TempFile("", fmt.Sprintf("test_%v_*_%s", i, name))
if err != nil {
t.Fatal(err)
}
io.Copy(tf, bytes.NewReader(data))
tf.Close()
t.Errorf("Actual data mismatched %s, written to %q", name, tf.Name())
}
}
if len(tc.expectedPayloadNames) == 0 {
tc.expectedPayloadNames = tc.names
}
compare(client.GetFile, tc.expectedPayloadNames[i])
if len(tc.expectedImageThumbnailNames) > i {
compare(client.GetFileThumbnail, tc.expectedImageThumbnailNames[i])
}
if len(tc.expectedImageThumbnailNames) > i {
compare(client.GetFilePreview, tc.expectedImagePreviewNames[i])
}
}
th.cleanupTestFile(dbInfo)
}
})
if restoreConfig != nil {
restoreConfig(th.App)
}
}
}
}

View File

@@ -11,7 +11,7 @@ import (
"image"
"image/color"
"image/draw"
_ "image/gif"
"image/gif"
"image/jpeg"
"io"
"mime/multipart"
@@ -30,6 +30,7 @@ import (
"github.com/mattermost/mattermost-server/model"
"github.com/mattermost/mattermost-server/plugin"
"github.com/mattermost/mattermost-server/services/filesstore"
"github.com/mattermost/mattermost-server/store"
"github.com/mattermost/mattermost-server/utils"
)
@@ -53,7 +54,15 @@ const (
RotatedCCWMirrored = 7
RotatedCW = 8
MaxImageSize = 6048 * 4032 // 24 megapixels, roughly 36MB as a raw image
MaxImageSize = 6048 * 4032 // 24 megapixels, roughly 36MB as a raw image
ImageThumbnailWidth = 120
ImageThumbnailHeight = 100
ImageThumbnailRatio = float64(ImageThumbnailHeight) / float64(ImageThumbnailWidth)
ImagePreviewWidth = 1920
UploadFileInitialBufferSize = 2 * 1024 * 1024 // 2Mb
// Deprecated
IMAGE_THUMBNAIL_PIXEL_WIDTH = 120
IMAGE_THUMBNAIL_PIXEL_HEIGHT = 100
IMAGE_PREVIEW_PIXEL_WIDTH = 1920
@@ -335,7 +344,8 @@ func (a *App) UploadMultipartFiles(teamId string, channelId string, userId strin
for i, fileHeader := range fileHeaders {
file, fileErr := fileHeader.Open()
if fileErr != nil {
return nil, model.NewAppError("UploadFiles", "api.file.upload_file.bad_parse.app_error", nil, fileErr.Error(), http.StatusBadRequest)
return nil, model.NewAppError("UploadFiles", "api.file.upload_file.read_request.app_error",
map[string]interface{}{"Filename": fileHeader.Filename}, fileErr.Error(), http.StatusBadRequest)
}
// Will be closed after UploadFiles returns
@@ -353,7 +363,7 @@ func (a *App) UploadMultipartFiles(teamId string, channelId string, userId strin
// The provided files should be closed by the caller so that they are not leaked.
func (a *App) UploadFiles(teamId string, channelId string, userId string, files []io.ReadCloser, filenames []string, clientIds []string, now time.Time) (*model.FileUploadResponse, *model.AppError) {
if len(*a.Config().FileSettings.DriverName) == 0 {
return nil, model.NewAppError("uploadFile", "api.file.upload_file.storage.app_error", nil, "", http.StatusNotImplemented)
return nil, model.NewAppError("UploadFiles", "api.file.upload_file.storage.app_error", nil, "", http.StatusNotImplemented)
}
if len(filenames) != len(files) || (len(clientIds) > 0 && len(clientIds) != len(files)) {
@@ -421,6 +431,407 @@ func (a *App) DoUploadFile(now time.Time, rawTeamId string, rawChannelId string,
return info, err
}
func UploadFileSetTeamId(teamId string) func(t *uploadFileTask) {
return func(t *uploadFileTask) {
t.TeamId = filepath.Base(teamId)
}
}
func UploadFileSetUserId(userId string) func(t *uploadFileTask) {
return func(t *uploadFileTask) {
t.UserId = filepath.Base(userId)
}
}
func UploadFileSetTimestamp(timestamp time.Time) func(t *uploadFileTask) {
return func(t *uploadFileTask) {
t.Timestamp = timestamp
}
}
func UploadFileSetContentLength(contentLength int64) func(t *uploadFileTask) {
return func(t *uploadFileTask) {
t.ContentLength = contentLength
}
}
func UploadFileSetClientId(clientId string) func(t *uploadFileTask) {
return func(t *uploadFileTask) {
t.ClientId = clientId
}
}
func UploadFileSetRaw() func(t *uploadFileTask) {
return func(t *uploadFileTask) {
t.Raw = true
}
}
type uploadFileTask struct {
// File name.
Name string
ChannelId string
TeamId string
UserId string
// Time stamp to use when creating the file.
Timestamp time.Time
// The value of the Content-Length http header, when available.
ContentLength int64
// The file data stream.
Input io.Reader
// An optional, client-assigned Id field.
ClientId string
// If Raw, do not execute special processing for images, just upload
// the file. Plugins are still invoked.
Raw bool
//=============================================================
// Internal state
buf *bytes.Buffer
limit int64
limitedInput io.Reader
teeInput io.Reader
fileinfo *model.FileInfo
maxFileSize int64
// Cached image data that (may) get initialized in preprocessImage and
// is used in postprocessImage
decoded image.Image
imageType string
imageOrientation int
// Testing: overrideable dependency functions
pluginsEnvironment *plugin.Environment
writeFile func(io.Reader, string) (int64, *model.AppError)
saveToDatabase func(*model.FileInfo) store.StoreChannel
}
func (t *uploadFileTask) init(a *App) {
t.buf = &bytes.Buffer{}
t.maxFileSize = *a.Config().FileSettings.MaxFileSize
t.limit = *a.Config().FileSettings.MaxFileSize
t.fileinfo = model.NewInfo(filepath.Base(t.Name))
t.fileinfo.Id = model.NewId()
t.fileinfo.CreatorId = t.UserId
t.fileinfo.CreateAt = t.Timestamp.UnixNano() / int64(time.Millisecond)
t.fileinfo.Path = t.pathPrefix() + t.Name
// Prepare to read ContentLength if it is known, otherwise limit
// ourselves to MaxFileSize. Add an extra byte to check and fail if the
// client sent too many bytes.
if t.ContentLength > 0 {
t.limit = t.ContentLength
// Over-Grow the buffer to prevent bytes.ReadFrom from doing it
// at the very end.
t.buf.Grow(int(t.limit + 1 + bytes.MinRead))
} else {
// If we don't know the upload size, grow the buffer somewhat
// anyway to avoid extra reslicing.
t.buf.Grow(UploadFileInitialBufferSize)
}
t.limitedInput = &io.LimitedReader{
R: t.Input,
N: t.limit + 1,
}
t.teeInput = io.TeeReader(t.limitedInput, t.buf)
t.pluginsEnvironment = a.GetPluginsEnvironment()
t.writeFile = a.WriteFile
t.saveToDatabase = a.Srv.Store.FileInfo().Save
}
// UploadFileX uploads a single file as specified in t. It applies the upload
// constraints, executes plugins and image processing logic as needed. It
// returns a filled-out FileInfo and an optional error. A plugin may reject the
// upload, returning a rejection error. In this case FileInfo would have
// contained the last "good" FileInfo before the execution of that plugin.
func (a *App) UploadFileX(channelId, name string, input io.Reader,
opts ...func(*uploadFileTask)) (*model.FileInfo, *model.AppError) {
t := &uploadFileTask{
ChannelId: filepath.Base(channelId),
Name: filepath.Base(name),
Input: input,
}
for _, o := range opts {
o(t)
}
t.init(a)
if len(*a.Config().FileSettings.DriverName) == 0 {
return nil, t.newAppError("api.file.upload_file.storage.app_error",
"", http.StatusNotImplemented)
}
if t.ContentLength > t.maxFileSize {
return nil, t.newAppError("api.file.upload_file.too_large_detailed.app_error",
"", http.StatusRequestEntityTooLarge, "Length", t.ContentLength, "Limit", t.maxFileSize)
}
var aerr *model.AppError
if !t.Raw && t.fileinfo.IsImage() {
aerr = t.preprocessImage()
if aerr != nil {
return t.fileinfo, aerr
}
}
aerr = t.readAll()
if aerr != nil {
return t.fileinfo, aerr
}
aerr = t.runPlugins()
if aerr != nil {
return t.fileinfo, aerr
}
// Concurrently upload and update DB, and post-process the image.
wg := sync.WaitGroup{}
if !t.Raw && t.fileinfo.IsImage() {
wg.Add(1)
go func() {
t.postprocessImage()
wg.Done()
}()
}
_, aerr = t.writeFile(t.newReader(), t.fileinfo.Path)
if aerr != nil {
return nil, aerr
}
if result := <-t.saveToDatabase(t.fileinfo); result.Err != nil {
return nil, result.Err
}
wg.Wait()
return t.fileinfo, nil
}
func (t *uploadFileTask) readAll() *model.AppError {
_, err := t.buf.ReadFrom(t.limitedInput)
if err != nil {
return t.newAppError("api.file.upload_file.read_request.app_error",
err.Error(), http.StatusBadRequest)
}
if int64(t.buf.Len()) > t.limit {
return t.newAppError("api.file.upload_file.too_large_detailed.app_error",
"", http.StatusRequestEntityTooLarge, "Length", t.buf.Len(), "Limit", t.limit)
}
t.fileinfo.Size = int64(t.buf.Len())
t.limitedInput = nil
t.teeInput = nil
return nil
}
func (t *uploadFileTask) runPlugins() *model.AppError {
if t.pluginsEnvironment == nil {
return nil
}
pluginContext := &plugin.Context{}
var rejectionError *model.AppError
t.pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool {
buf := &bytes.Buffer{}
replacementInfo, rejectionReason := hooks.FileWillBeUploaded(pluginContext,
t.fileinfo, t.newReader(), buf)
if rejectionReason != "" {
rejectionError = t.newAppError("api.file.upload_file.read_request.app_error",
rejectionReason, http.StatusBadRequest)
return false
}
if replacementInfo != nil {
t.fileinfo = replacementInfo
}
if buf.Len() != 0 {
t.buf = buf
t.teeInput = nil
t.limitedInput = nil
t.fileinfo.Size = int64(buf.Len())
}
return true
}, plugin.FileWillBeUploadedId)
if rejectionError != nil {
return rejectionError
}
return nil
}
func (t *uploadFileTask) preprocessImage() *model.AppError {
// If we fail to decode, return "as is".
config, _, err := image.DecodeConfig(t.newReader())
if err != nil {
return nil
}
t.fileinfo.Width = config.Width
t.fileinfo.Height = config.Height
// Check dimensions before loading the whole thing into memory later on.
if t.fileinfo.Width*t.fileinfo.Height > MaxImageSize {
return t.newAppError("api.file.upload_file.large_image_detailed.app_error",
"", http.StatusBadRequest)
}
t.fileinfo.HasPreviewImage = true
nameWithoutExtension := t.Name[:strings.LastIndex(t.Name, ".")]
t.fileinfo.PreviewPath = t.pathPrefix() + nameWithoutExtension + "_preview.jpg"
t.fileinfo.ThumbnailPath = t.pathPrefix() + nameWithoutExtension + "_thumb.jpg"
// check the image orientation with goexif; consume the bytes we
// already have first, then keep Tee-ing from input.
// TODO: try to reuse exif's .Raw buffer rather than Tee-ing
if t.imageOrientation, err = getImageOrientation(t.newReader()); err == nil &&
(t.imageOrientation == RotatedCWMirrored ||
t.imageOrientation == RotatedCCW ||
t.imageOrientation == RotatedCCWMirrored ||
t.imageOrientation == RotatedCW) {
t.fileinfo.Width, t.fileinfo.Height = t.fileinfo.Height, t.fileinfo.Width
}
// For animated GIFs disable the preview; since we have to Decode gifs
// anyway, cache the decoded image for later.
if t.fileinfo.MimeType == "image/gif" {
gifConfig, err := gif.DecodeAll(t.newReader())
if err == nil {
if len(gifConfig.Image) >= 1 {
t.fileinfo.HasPreviewImage = false
}
if len(gifConfig.Image) > 0 {
t.decoded = gifConfig.Image[0]
t.imageType = "gif"
}
}
}
return nil
}
func (t *uploadFileTask) postprocessImage() {
decoded, typ := t.decoded, t.imageType
if decoded == nil {
var err error
decoded, typ, err = image.Decode(t.newReader())
if err != nil {
mlog.Error(fmt.Sprintf("Unable to decode image err=%v", err))
return
}
}
// Fill in the background of a potentially-transparent png file as
// white.
if typ == "png" {
dst := image.NewRGBA(decoded.Bounds())
draw.Draw(dst, dst.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
draw.Draw(dst, dst.Bounds(), decoded, decoded.Bounds().Min, draw.Over)
decoded = dst
}
decoded = makeImageUpright(decoded, t.imageOrientation)
if decoded == nil {
return
}
writeJPEG := func(img image.Image, path string) {
r, w := io.Pipe()
go func() {
_, aerr := t.writeFile(r, path)
if aerr != nil {
mlog.Error(fmt.Sprintf("Unable to upload path=%v err=%v", path, aerr))
return
}
}()
err := jpeg.Encode(w, img, &jpeg.Options{Quality: 90})
if err != nil {
mlog.Error(fmt.Sprintf("Unable to encode image as jpeg path=%v err=%v", path, err))
w.CloseWithError(err)
} else {
w.Close()
}
}
w := decoded.Bounds().Dx()
h := decoded.Bounds().Dy()
wg := &sync.WaitGroup{}
wg.Add(2)
go func() {
defer wg.Done()
thumb := decoded
if h > ImageThumbnailHeight || w > ImageThumbnailWidth {
if float64(h)/float64(w) < ImageThumbnailRatio {
thumb = imaging.Resize(decoded, 0, ImageThumbnailHeight, imaging.Lanczos)
} else {
thumb = imaging.Resize(decoded, ImageThumbnailWidth, 0, imaging.Lanczos)
}
}
writeJPEG(thumb, t.fileinfo.ThumbnailPath)
}()
go func() {
defer wg.Done()
preview := decoded
if w > ImagePreviewWidth {
preview = imaging.Resize(decoded, ImagePreviewWidth, 0, imaging.Lanczos)
}
writeJPEG(preview, t.fileinfo.PreviewPath)
}()
wg.Wait()
}
func (t uploadFileTask) newReader() io.Reader {
if t.teeInput != nil {
return io.MultiReader(bytes.NewReader(t.buf.Bytes()), t.teeInput)
} else {
return bytes.NewReader(t.buf.Bytes())
}
}
func (t uploadFileTask) pathPrefix() string {
return t.Timestamp.Format("20060102") +
"/teams/" + t.TeamId +
"/channels/" + t.ChannelId +
"/users/" + t.UserId +
"/" + t.fileinfo.Id + "/"
}
func (t uploadFileTask) newAppError(id string, details interface{}, httpStatus int, extra ...interface{}) *model.AppError {
params := map[string]interface{}{
"Name": t.Name,
"Filename": t.Name,
"ChannelId": t.ChannelId,
"TeamId": t.TeamId,
"UserId": t.UserId,
"ContentLength": t.ContentLength,
"ClientId": t.ClientId,
}
if t.fileinfo != nil {
params["Width"] = t.fileinfo.Width
params["Height"] = t.fileinfo.Height
}
for i := 0; i+1 < len(extra); i += 2 {
params[fmt.Sprintf("%v", extra[i])] = extra[i+1]
}
return model.NewAppError("uploadFileTask", id, params, fmt.Sprintf("%v", details), httpStatus)
}
func (a *App) DoUploadFileExpectModification(now time.Time, rawTeamId string, rawChannelId string, rawUserId string, rawFilename string, data []byte) (*model.FileInfo, []byte, *model.AppError) {
filename := filepath.Base(rawFilename)
teamId := filepath.Base(rawTeamId)
@@ -503,21 +914,21 @@ func (a *App) HandleImages(previewPathList []string, thumbnailPathList []string,
img, width, height := prepareImage(fileData[i])
if img != nil {
wg.Add(2)
go func(img *image.Image, path string, width int, height int) {
go func(img image.Image, path string, width int, height int) {
defer wg.Done()
a.generateThumbnailImage(*img, path, width, height)
a.generateThumbnailImage(img, path, width, height)
}(img, thumbnailPathList[i], width, height)
go func(img *image.Image, path string, width int) {
go func(img image.Image, path string, width int) {
defer wg.Done()
a.generatePreviewImage(*img, path, width)
a.generatePreviewImage(img, path, width)
}(img, previewPathList[i], width)
}
}
wg.Wait()
}
func prepareImage(fileData []byte) (*image.Image, int, int) {
func prepareImage(fileData []byte) (image.Image, int, int) {
// Decode image bytes into Image object
img, imgType, err := image.Decode(bytes.NewReader(fileData))
if err != nil {
@@ -540,7 +951,7 @@ func prepareImage(fileData []byte) (*image.Image, int, int) {
orientation, _ := getImageOrientation(bytes.NewReader(fileData))
img = makeImageUpright(img, orientation)
return &img, width, height
return img, width, height
}
func makeImageUpright(img image.Image, orientation int) image.Image {

195
app/file_bench_test.go Normal file
View File

@@ -0,0 +1,195 @@
// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
package app
import (
"bytes"
"fmt"
"image"
"image/gif"
"image/jpeg"
"io"
"io/ioutil"
"math/rand"
"testing"
"time"
"github.com/mattermost/mattermost-server/mlog"
"github.com/mattermost/mattermost-server/model"
)
var randomJPEG []byte
var randomGIF []byte
var zero10M = make([]byte, 10*1024*1024)
var rgba *image.RGBA
func prepareTestImages(tb testing.TB) {
if rgba != nil {
return
}
// Create a random image (pre-seeded for predictability)
rgba = image.NewRGBA(image.Rectangle{
image.Point{0, 0},
image.Point{2048, 2048},
})
_, err := rand.New(rand.NewSource(1)).Read(rgba.Pix)
if err != nil {
tb.Fatal(err)
}
// Encode it as JPEG and GIF
buf := &bytes.Buffer{}
err = jpeg.Encode(buf, rgba, &jpeg.Options{Quality: 50})
if err != nil {
tb.Fatal(err)
}
randomJPEG = buf.Bytes()
buf = &bytes.Buffer{}
err = gif.Encode(buf, rgba, nil)
if err != nil {
tb.Fatal(err)
}
randomGIF = buf.Bytes()
}
func BenchmarkUploadFile(b *testing.B) {
prepareTestImages(b)
th := Setup().InitBasic()
defer th.TearDown()
// disable logging in the benchmark, as best we can
th.App.Log.SetConsoleLevel(mlog.LevelError)
teamId := model.NewId()
channelId := model.NewId()
userId := model.NewId()
mb := func(i int) int {
return (i + 512*1024) / (1024 * 1024)
}
files := []struct {
title string
ext string
data []byte
}{
{fmt.Sprintf("random-%dMb-gif", mb(len(randomGIF))), ".gif", randomGIF},
{fmt.Sprintf("random-%dMb-jpg", mb(len(randomJPEG))), ".jpg", randomJPEG},
{fmt.Sprintf("zero-%dMb", mb(len(zero10M))), ".zero", zero10M},
}
file_benchmarks := []struct {
title string
f func(b *testing.B, n int, data []byte, ext string)
}{
{
title: "raw-ish DoUploadFile",
f: func(b *testing.B, n int, data []byte, ext string) {
info1, err := th.App.DoUploadFile(time.Now(), teamId, channelId,
userId, fmt.Sprintf("BenchmarkDoUploadFile-%d%s", n, ext), data)
if err != nil {
b.Fatal(err)
}
<-th.App.Srv.Store.FileInfo().PermanentDelete(info1.Id)
th.App.RemoveFile(info1.Path)
},
},
{
title: "raw UploadFileX Content-Length",
f: func(b *testing.B, n int, data []byte, ext string) {
info, aerr := th.App.UploadFileX(channelId,
fmt.Sprintf("BenchmarkUploadFileTask-%d%s", n, ext),
bytes.NewReader(data),
UploadFileSetTeamId(teamId),
UploadFileSetUserId(userId),
UploadFileSetTimestamp(time.Now()),
UploadFileSetContentLength(int64(len(data))),
UploadFileSetRaw())
if aerr != nil {
b.Fatal(aerr)
}
<-th.App.Srv.Store.FileInfo().PermanentDelete(info.Id)
th.App.RemoveFile(info.Path)
},
},
{
title: "raw UploadFileX chunked",
f: func(b *testing.B, n int, data []byte, ext string) {
info, aerr := th.App.UploadFileX(channelId,
fmt.Sprintf("BenchmarkUploadFileTask-%d%s", n, ext),
bytes.NewReader(data),
UploadFileSetTeamId(teamId),
UploadFileSetUserId(userId),
UploadFileSetTimestamp(time.Now()),
UploadFileSetContentLength(-1),
UploadFileSetRaw())
if aerr != nil {
b.Fatal(aerr)
}
<-th.App.Srv.Store.FileInfo().PermanentDelete(info.Id)
th.App.RemoveFile(info.Path)
},
},
{
title: "image UploadFiles",
f: func(b *testing.B, n int, data []byte, ext string) {
resp, err := th.App.UploadFiles(teamId, channelId, userId,
[]io.ReadCloser{ioutil.NopCloser(bytes.NewReader(data))},
[]string{fmt.Sprintf("BenchmarkDoUploadFiles-%d%s", n, ext)},
[]string{},
time.Now())
if err != nil {
b.Fatal(err)
}
<-th.App.Srv.Store.FileInfo().PermanentDelete(resp.FileInfos[0].Id)
th.App.RemoveFile(resp.FileInfos[0].Path)
},
},
{
title: "image UploadFileX Content-Length",
f: func(b *testing.B, n int, data []byte, ext string) {
info, aerr := th.App.UploadFileX(channelId,
fmt.Sprintf("BenchmarkUploadFileTask-%d%s", n, ext),
bytes.NewReader(data),
UploadFileSetTeamId(teamId),
UploadFileSetUserId(userId),
UploadFileSetTimestamp(time.Now()),
UploadFileSetContentLength(int64(len(data))))
if aerr != nil {
b.Fatal(aerr)
}
<-th.App.Srv.Store.FileInfo().PermanentDelete(info.Id)
th.App.RemoveFile(info.Path)
},
},
{
title: "image UploadFileX chunked",
f: func(b *testing.B, n int, data []byte, ext string) {
info, aerr := th.App.UploadFileX(channelId,
fmt.Sprintf("BenchmarkUploadFileTask-%d%s", n, ext),
bytes.NewReader(data),
UploadFileSetTeamId(teamId),
UploadFileSetUserId(userId),
UploadFileSetTimestamp(time.Now()),
UploadFileSetContentLength(int64(len(data))))
if aerr != nil {
b.Fatal(aerr)
}
<-th.App.Srv.Store.FileInfo().PermanentDelete(info.Id)
th.App.RemoveFile(info.Path)
},
},
}
for _, file := range files {
for _, fb := range file_benchmarks {
b.Run(file.title+"-"+fb.title, func(b *testing.B) {
for i := 0; i < b.N; i++ {
fb.f(b, i, file.data, file.ext)
}
})
}
}
}

View File

@@ -97,8 +97,8 @@ func TestDoUploadFile(t *testing.T) {
t.Fatal(err)
} else {
defer func() {
<-th.App.Srv.Store.FileInfo().PermanentDelete(info3.Id)
th.App.RemoveFile(info3.Path)
<-th.App.Srv.Store.FileInfo().PermanentDelete(info4.Id)
th.App.RemoveFile(info4.Path)
}()
}

View File

@@ -780,8 +780,8 @@ func (a *App) OldImportFile(timestamp time.Time, file io.Reader, teamId string,
if fileInfo.IsImage() && fileInfo.MimeType != "image/svg+xml" {
img, width, height := prepareImage(data)
if img != nil {
a.generateThumbnailImage(*img, fileInfo.ThumbnailPath, width, height)
a.generatePreviewImage(*img, fileInfo.PreviewPath, width)
a.generateThumbnailImage(img, fileInfo.ThumbnailPath, width, height)
a.generatePreviewImage(img, fileInfo.PreviewPath, width)
}
}

View File

@@ -1201,13 +1201,29 @@
"translation": "Bad connection to S3 or minio."
},
{
"id": "api.file.upload_file.bad_parse.app_error",
"translation": "Unable to upload file. Header cannot be parsed."
"id": "api.file.upload_file.read_request.app_error",
"translation": "Unable to upload file(s). Error reading or parsing request data."
},
{
"id": "api.file.upload_file.read_form_value.app_error",
"translation": "Unable to upload file(s). Error reading the value for {{.Formname}}."
},
{
"id": "api.file.upload_file.multiple_channel_ids.app_error",
"translation": "Unable to upload file(s). Multiple conflicting channel_ids."
},
{
"id": "api.file.upload_file.incorrect_number_of_client_ids.app_error",
"translation": "Unable to upload file(s). Have {{.NumClientIds}} client_ids for {{.NumFiles}} files."
},
{
"id": "api.file.upload_file.incorrect_number_of_files.app_error",
"translation": "Unable to upload files. Incorrect number of files specified."
},
{
"id": "api.file.upload_file.large_image_detailed.app_error",
"translation": "{{.Filename}} dimensions ({{.Width}} by {{.Height}} pixels) exceed the limits"
},
{
"id": "api.file.upload_file.large_image.app_error",
"translation": "File above maximum dimensions could not be uploaded: {{.Filename}}"
@@ -1220,6 +1236,10 @@
"id": "api.file.upload_file.too_large.app_error",
"translation": "Unable to upload file. File is too large."
},
{
"id": "api.file.upload_file.too_large_detailed.app_error",
"translation": "Unable to upload file {{.Filename}}. {{.Length}} bytes exceeds the maximum allowed {{.Limit}} bytes."
},
{
"id": "api.file.write_file.s3.app_error",
"translation": "Encountered an error writing to S3"

View File

@@ -472,7 +472,14 @@ func (c *Client4) doApiRequestReader(method, url string, data io.Reader, etag st
}
func (c *Client4) DoUploadFile(url string, data []byte, contentType string) (*FileUploadResponse, *Response) {
rq, _ := http.NewRequest("POST", c.ApiUrl+url, bytes.NewReader(data))
return c.doUploadFile(url, bytes.NewReader(data), contentType, 0)
}
func (c *Client4) doUploadFile(url string, body io.Reader, contentType string, contentLength int64) (*FileUploadResponse, *Response) {
rq, _ := http.NewRequest("POST", c.ApiUrl+url, body)
if contentLength != 0 {
rq.ContentLength = contentLength
}
rq.Header.Set("Content-Type", contentType)
if len(c.AuthToken) > 0 {
@@ -2290,33 +2297,33 @@ func (c *Client4) SubmitInteractiveDialog(request SubmitDialogRequest) (*SubmitD
return &resp, BuildResponse(r)
}
// File Section
// UploadFile will upload a file to a channel using a multipart request, to be later attached to a post.
// This method is functionally equivalent to Client4.UploadFileAsRequestBody.
func (c *Client4) UploadFile(data []byte, channelId string, filename string) (*FileUploadResponse, *Response) {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("files", filename)
if err != nil {
return nil, &Response{Error: NewAppError("UploadPostAttachment", "model.client.upload_post_attachment.file.app_error", nil, err.Error(), http.StatusBadRequest)}
}
if _, err = io.Copy(part, bytes.NewBuffer(data)); err != nil {
return nil, &Response{Error: NewAppError("UploadPostAttachment", "model.client.upload_post_attachment.file.app_error", nil, err.Error(), http.StatusBadRequest)}
}
part, err = writer.CreateFormField("channel_id")
part, err := writer.CreateFormField("channel_id")
if err != nil {
return nil, &Response{Error: NewAppError("UploadPostAttachment", "model.client.upload_post_attachment.channel_id.app_error", nil, err.Error(), http.StatusBadRequest)}
}
if _, err := io.Copy(part, strings.NewReader(channelId)); err != nil {
_, err = io.Copy(part, strings.NewReader(channelId))
if err != nil {
return nil, &Response{Error: NewAppError("UploadPostAttachment", "model.client.upload_post_attachment.channel_id.app_error", nil, err.Error(), http.StatusBadRequest)}
}
if err := writer.Close(); err != nil {
part, err = writer.CreateFormFile("files", filename)
if err != nil {
return nil, &Response{Error: NewAppError("UploadPostAttachment", "model.client.upload_post_attachment.file.app_error", nil, err.Error(), http.StatusBadRequest)}
}
_, err = io.Copy(part, bytes.NewBuffer(data))
if err != nil {
return nil, &Response{Error: NewAppError("UploadPostAttachment", "model.client.upload_post_attachment.file.app_error", nil, err.Error(), http.StatusBadRequest)}
}
err = writer.Close()
if err != nil {
return nil, &Response{Error: NewAppError("UploadPostAttachment", "model.client.upload_post_attachment.writer.app_error", nil, err.Error(), http.StatusBadRequest)}
}

View File

@@ -112,6 +112,24 @@ func (o *FileInfo) IsImage() bool {
return strings.HasPrefix(o.MimeType, "image")
}
func NewInfo(name string) *FileInfo {
info := &FileInfo{
Name: name,
}
extension := strings.ToLower(filepath.Ext(name))
info.MimeType = mime.TypeByExtension(extension)
if extension != "" && extension[0] == '.' {
// The client expects a file extension without the leading period
info.Extension = extension[1:]
} else {
info.Extension = extension
}
return info
}
func GetInfoForBytes(name string, data []byte) (*FileInfo, *AppError) {
info := &FileInfo{
Name: name,

View File

@@ -19,11 +19,6 @@ import (
type apiImplCreatorFunc func(*model.Manifest) API
// multiPluginHookRunnerFunc is a callback function to invoke as part of RunMultiPluginHook.
//
// Return false to stop the hook from iterating to subsequent plugins.
type multiPluginHookRunnerFunc func(hooks Hooks) bool
type activePlugin struct {
BundleInfo *model.BundleInfo
State int
@@ -288,7 +283,7 @@ func (env *Environment) HooksForPlugin(id string) (Hooks, error) {
//
// If hookRunnerFunc returns false, iteration will not continue. The iteration order among active
// plugins is not specified.
func (env *Environment) RunMultiPluginHook(hookRunnerFunc multiPluginHookRunnerFunc, hookId int) {
func (env *Environment) RunMultiPluginHook(hookRunnerFunc func(hooks Hooks) bool, hookId int) {
env.activePlugins.Range(func(key, value interface{}) bool {
activePlugin := value.(activePlugin)

BIN
tests/orientation_test.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 575 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 576 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB