summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--api/api.go8
-rw-r--r--api/authorization.go36
-rw-r--r--api/auto_posts.go37
-rw-r--r--api/channel_benchmark_test.go284
-rw-r--r--api/file.go649
-rw-r--r--api/file_benchmark_test.go70
-rw-r--r--api/file_test.go898
-rw-r--r--api/post.go166
-rw-r--r--api/post_benchmark_test.go148
-rw-r--r--api/post_test.go131
-rw-r--r--i18n/en.json166
-rw-r--r--model/client.go111
-rw-r--r--model/compliance_post.go6
-rw-r--r--model/compliance_post_test.go2
-rw-r--r--model/file.go4
-rw-r--r--model/file_info.go173
-rw-r--r--model/file_info_test.go165
-rw-r--r--model/post.go14
-rw-r--r--store/sql_channel_store.go59
-rw-r--r--store/sql_channel_store_test.go56
-rw-r--r--store/sql_compliance_store.go2
-rw-r--r--store/sql_file_info_store.go197
-rw-r--r--store/sql_file_info_store_test.go208
-rw-r--r--store/sql_post_store.go28
-rw-r--r--store/sql_post_store_test.go58
-rw-r--r--store/sql_store.go7
-rw-r--r--store/sql_upgrade.go4
-rw-r--r--store/store.go14
-rw-r--r--utils/utils.go14
-rw-r--r--utils/utils_test.go16
-rw-r--r--webapp/actions/global_actions.jsx5
-rw-r--r--webapp/client/client.jsx71
-rw-r--r--webapp/components/audio_video_preview.jsx12
-rw-r--r--webapp/components/code_preview.jsx18
-rw-r--r--webapp/components/create_comment.jsx36
-rw-r--r--webapp/components/create_post.jsx47
-rw-r--r--webapp/components/file_attachment.jsx251
-rw-r--r--webapp/components/file_attachment_list.jsx55
-rw-r--r--webapp/components/file_attachment_list_container.jsx90
-rw-r--r--webapp/components/file_info_preview.jsx88
-rw-r--r--webapp/components/file_preview.jsx83
-rw-r--r--webapp/components/file_upload.jsx9
-rw-r--r--webapp/components/get_public_link_modal.jsx6
-rw-r--r--webapp/components/pdf_preview.jsx24
-rw-r--r--webapp/components/post_view/components/commented_on_files_message_container.jsx88
-rw-r--r--webapp/components/post_view/components/post_body.jsx31
-rw-r--r--webapp/components/rhs_comment.jsx12
-rw-r--r--webapp/components/rhs_root_post.jsx12
-rw-r--r--webapp/components/view_image.jsx140
-rw-r--r--webapp/sass/components/_files.scss4
-rw-r--r--webapp/stores/file_store.jsx43
-rw-r--r--webapp/stores/notification_store.jsx2
-rw-r--r--webapp/stores/post_store.jsx6
-rw-r--r--webapp/tests/client_file.test.jsx248
-rw-r--r--webapp/tests/client_general.test.jsx38
-rw-r--r--webapp/tests/client_post.test.jsx2
-rw-r--r--webapp/utils/async_client.jsx60
-rw-r--r--webapp/utils/constants.jsx2
-rw-r--r--webapp/utils/syntax_hightlighting.jsx9
-rw-r--r--webapp/utils/utils.jsx17
60 files changed, 3316 insertions, 1924 deletions
diff --git a/api/api.go b/api/api.go
index 492c3b0a9..eea70c9b5 100644
--- a/api/api.go
+++ b/api/api.go
@@ -33,7 +33,9 @@ type Routes struct {
Commands *mux.Router // 'api/v3/teams/{team_id:[A-Za-z0-9]+}/commands'
Hooks *mux.Router // 'api/v3/teams/{team_id:[A-Za-z0-9]+}/hooks'
- Files *mux.Router // 'api/v3/teams/{team_id:[A-Za-z0-9]+}/files'
+ TeamFiles *mux.Router // 'api/v3/teams/{team_id:[A-Za-z0-9]+}/files'
+ Files *mux.Router // 'api/v3/files'
+ NeedFile *mux.Router // 'api/v3/files/{file_id:[A-Za-z0-9]+}'
OAuth *mux.Router // 'api/v3/oauth'
@@ -70,7 +72,9 @@ func InitApi() {
BaseRoutes.Posts = BaseRoutes.NeedChannel.PathPrefix("/posts").Subrouter()
BaseRoutes.NeedPost = BaseRoutes.Posts.PathPrefix("/{post_id:[A-Za-z0-9]+}").Subrouter()
BaseRoutes.Commands = BaseRoutes.NeedTeam.PathPrefix("/commands").Subrouter()
- BaseRoutes.Files = BaseRoutes.NeedTeam.PathPrefix("/files").Subrouter()
+ BaseRoutes.TeamFiles = BaseRoutes.NeedTeam.PathPrefix("/files").Subrouter()
+ BaseRoutes.Files = BaseRoutes.ApiRoot.PathPrefix("/files").Subrouter()
+ BaseRoutes.NeedFile = BaseRoutes.Files.PathPrefix("/{file_id:[A-Za-z0-9]+}").Subrouter()
BaseRoutes.Hooks = BaseRoutes.NeedTeam.PathPrefix("/hooks").Subrouter()
BaseRoutes.OAuth = BaseRoutes.ApiRoot.PathPrefix("/oauth").Subrouter()
BaseRoutes.Admin = BaseRoutes.ApiRoot.PathPrefix("/admin").Subrouter()
diff --git a/api/authorization.go b/api/authorization.go
index fb04b069b..5badf244b 100644
--- a/api/authorization.go
+++ b/api/authorization.go
@@ -114,6 +114,42 @@ func HasPermissionToChannel(user *model.User, teamMember *model.TeamMember, chan
return HasPermissionToTeam(user, teamMember, permission)
}
+func HasPermissionToChannelByPostContext(c *Context, postId string, permission *model.Permission) bool {
+ cmc := Srv.Store.Channel().GetMemberForPost(postId, c.Session.UserId)
+
+ var channelRoles []string
+ if cmcresult := <-cmc; cmcresult.Err == nil {
+ channelMember := cmcresult.Data.(*model.ChannelMember)
+ channelRoles = channelMember.GetRoles()
+
+ if CheckIfRolesGrantPermission(channelRoles, permission.Id) {
+ return true
+ }
+ }
+
+ cc := Srv.Store.Channel().GetForPost(postId)
+ if ccresult := <-cc; ccresult.Err == nil {
+ channel := ccresult.Data.(*model.Channel)
+
+ if teamMember := c.Session.GetTeamByTeamId(channel.TeamId); teamMember != nil {
+ roles := teamMember.GetRoles()
+
+ if CheckIfRolesGrantPermission(roles, permission.Id) {
+ return true
+ }
+ }
+
+ }
+
+ if HasPermissionToContext(c, permission) {
+ return true
+ }
+
+ c.Err = model.NewLocAppError("HasPermissionToChannelByPostContext", "api.context.permissions.app_error", nil, "userId="+c.Session.UserId+", "+"permission="+permission.Id+" channelRoles="+model.RoleIdsToString(channelRoles))
+ c.Err.StatusCode = http.StatusForbidden
+ return false
+}
+
func HasPermissionToUser(c *Context, userId string) bool {
// You are the user (users autmaticly have permissions to themselves)
if c.Session.UserId == userId {
diff --git a/api/auto_posts.go b/api/auto_posts.go
index 2e26e513b..6b1207c10 100644
--- a/api/auto_posts.go
+++ b/api/auto_posts.go
@@ -8,7 +8,6 @@ import (
"github.com/mattermost/platform/model"
"github.com/mattermost/platform/utils"
"io"
- "mime/multipart"
"os"
)
@@ -40,53 +39,31 @@ func NewAutoPostCreator(client *model.Client, channelid string) *AutoPostCreator
}
func (cfg *AutoPostCreator) UploadTestFile() ([]string, bool) {
- body := &bytes.Buffer{}
- writer := multipart.NewWriter(body)
-
filename := cfg.ImageFilenames[utils.RandIntFromRange(utils.Range{0, len(cfg.ImageFilenames) - 1})]
- part, err := writer.CreateFormFile("files", filename)
- if err != nil {
- return nil, false
- }
-
path := utils.FindDir("web/static/images")
file, err := os.Open(path + "/" + filename)
defer file.Close()
- _, err = io.Copy(part, file)
- if err != nil {
- return nil, false
- }
-
- field, err := writer.CreateFormField("channel_id")
- if err != nil {
- return nil, false
- }
-
- _, err = field.Write([]byte(cfg.channelid))
- if err != nil {
- return nil, false
- }
-
- err = writer.Close()
+ data := &bytes.Buffer{}
+ _, err = io.Copy(data, file)
if err != nil {
return nil, false
}
- resp, appErr := cfg.client.UploadPostAttachment(body.Bytes(), writer.FormDataContentType())
+ resp, appErr := cfg.client.UploadPostAttachment(data.Bytes(), cfg.channelid, filename)
if appErr != nil {
return nil, false
}
- return resp.Data.(*model.FileUploadResponse).Filenames, true
+ return []string{resp.FileInfos[0].Id}, true
}
func (cfg *AutoPostCreator) CreateRandomPost() (*model.Post, bool) {
- var filenames []string
+ var fileIds []string
if cfg.HasImage {
var err1 bool
- filenames, err1 = cfg.UploadTestFile()
+ fileIds, err1 = cfg.UploadTestFile()
if err1 == false {
return nil, false
}
@@ -102,7 +79,7 @@ func (cfg *AutoPostCreator) CreateRandomPost() (*model.Post, bool) {
post := &model.Post{
ChannelId: cfg.channelid,
Message: postText,
- Filenames: filenames}
+ FileIds: fileIds}
result, err2 := cfg.client.CreatePost(post)
if err2 != nil {
return nil, false
diff --git a/api/channel_benchmark_test.go b/api/channel_benchmark_test.go
deleted file mode 100644
index 569c2dcc0..000000000
--- a/api/channel_benchmark_test.go
+++ /dev/null
@@ -1,284 +0,0 @@
-// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
-// See License.txt for license information.
-
-package api
-
-import (
- "github.com/mattermost/platform/model"
- "github.com/mattermost/platform/store"
- "github.com/mattermost/platform/utils"
- "testing"
-)
-
-const (
- NUM_CHANNELS = 140
- NUM_USERS = 40
-)
-
-func BenchmarkCreateChannel(b *testing.B) {
- th := Setup().InitBasic()
-
- channelCreator := NewAutoChannelCreator(th.BasicClient, th.BasicTeam)
-
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- channelCreator.CreateTestChannels(utils.Range{NUM_CHANNELS, NUM_CHANNELS})
- }
-}
-
-func BenchmarkCreateDirectChannel(b *testing.B) {
- th := Setup().InitBasic()
-
- userCreator := NewAutoUserCreator(th.BasicClient, th.BasicTeam)
- users, err := userCreator.CreateTestUsers(utils.Range{NUM_USERS, NUM_USERS})
- if err == false {
- b.Fatal("Could not create users")
- }
-
- // Benchmark Start
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- for j := 0; j < NUM_USERS; j++ {
- th.BasicClient.CreateDirectChannel(users[j].Id)
- }
- }
-}
-
-func BenchmarkUpdateChannel(b *testing.B) {
- th := Setup().InitBasic()
-
- var (
- NUM_CHANNELS_RANGE = utils.Range{NUM_CHANNELS, NUM_CHANNELS}
- CHANNEL_HEADER_LEN = 50
- )
-
- channelCreator := NewAutoChannelCreator(th.BasicClient, th.BasicTeam)
- channels, valid := channelCreator.CreateTestChannels(NUM_CHANNELS_RANGE)
- if valid == false {
- b.Fatal("Unable to create test channels")
- }
-
- for i := range channels {
- channels[i].Header = utils.RandString(CHANNEL_HEADER_LEN, utils.ALPHANUMERIC)
- }
-
- // Benchmark Start
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- for j := range channels {
- if _, err := th.BasicClient.UpdateChannel(channels[j]); err != nil {
- b.Fatal(err)
- }
- }
- }
-}
-
-func BenchmarkGetChannels(b *testing.B) {
- th := Setup().InitBasic()
-
- var (
- NUM_CHANNELS_RANGE = utils.Range{NUM_CHANNELS, NUM_CHANNELS}
- )
-
- channelCreator := NewAutoChannelCreator(th.BasicClient, th.BasicTeam)
- _, valid := channelCreator.CreateTestChannels(NUM_CHANNELS_RANGE)
- if valid == false {
- b.Fatal("Unable to create test channels")
- }
-
- // Benchmark Start
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- th.BasicClient.Must(th.BasicClient.GetChannels(""))
- }
-}
-
-func BenchmarkGetMoreChannels(b *testing.B) {
- th := Setup().InitBasic()
-
- var (
- NUM_CHANNELS_RANGE = utils.Range{NUM_CHANNELS, NUM_CHANNELS}
- )
-
- channelCreator := NewAutoChannelCreator(th.BasicClient, th.BasicTeam)
- _, valid := channelCreator.CreateTestChannels(NUM_CHANNELS_RANGE)
- if valid == false {
- b.Fatal("Unable to create test channels")
- }
-
- // Benchmark Start
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- th.BasicClient.Must(th.BasicClient.GetMoreChannels(""))
- }
-}
-
-func BenchmarkJoinChannel(b *testing.B) {
- th := Setup().InitBasic()
-
- var (
- NUM_CHANNELS_RANGE = utils.Range{NUM_CHANNELS, NUM_CHANNELS}
- )
-
- channelCreator := NewAutoChannelCreator(th.BasicClient, th.BasicTeam)
- channels, valid := channelCreator.CreateTestChannels(NUM_CHANNELS_RANGE)
- if valid == false {
- b.Fatal("Unable to create test channels")
- }
-
- // Secondary test user to join channels created by primary test user
- user := &model.User{Email: "success+" + model.NewId() + "@simulator.amazonses.com", Nickname: "That Guy", Password: "pwd"}
- user = th.BasicClient.Must(th.BasicClient.CreateUser(user, "")).Data.(*model.User)
- LinkUserToTeam(user, th.BasicTeam)
- store.Must(Srv.Store.User().VerifyEmail(user.Id))
- th.BasicClient.Login(user.Email, "pwd")
-
- // Benchmark Start
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- for j := range channels {
- th.BasicClient.Must(th.BasicClient.JoinChannel(channels[j].Id))
- }
- }
-}
-
-func BenchmarkDeleteChannel(b *testing.B) {
- th := Setup().InitBasic()
-
- var (
- NUM_CHANNELS_RANGE = utils.Range{NUM_CHANNELS, NUM_CHANNELS}
- )
-
- channelCreator := NewAutoChannelCreator(th.BasicClient, th.BasicTeam)
- channels, valid := channelCreator.CreateTestChannels(NUM_CHANNELS_RANGE)
- if valid == false {
- b.Fatal("Unable to create test channels")
- }
-
- // Benchmark Start
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- for j := range channels {
- th.BasicClient.Must(th.BasicClient.DeleteChannel(channels[j].Id))
- }
- }
-}
-
-func BenchmarkGetChannelExtraInfo(b *testing.B) {
- th := Setup().InitBasic()
-
- var (
- NUM_CHANNELS_RANGE = utils.Range{NUM_CHANNELS, NUM_CHANNELS}
- )
-
- channelCreator := NewAutoChannelCreator(th.BasicClient, th.BasicTeam)
- channels, valid := channelCreator.CreateTestChannels(NUM_CHANNELS_RANGE)
- if valid == false {
- b.Fatal("Unable to create test channels")
- }
-
- // Benchmark Start
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- for j := range channels {
- th.BasicClient.Must(th.BasicClient.GetChannelExtraInfo(channels[j].Id, -1, ""))
- }
- }
-}
-
-func BenchmarkAddChannelMember(b *testing.B) {
- th := Setup().InitBasic()
-
- var (
- NUM_USERS = 100
- NUM_USERS_RANGE = utils.Range{NUM_USERS, NUM_USERS}
- )
-
- channel := &model.Channel{DisplayName: "Test Channel", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: th.BasicTeam.Id}
- channel = th.BasicClient.Must(th.BasicClient.CreateChannel(channel)).Data.(*model.Channel)
-
- userCreator := NewAutoUserCreator(th.BasicClient, th.BasicTeam)
- users, valid := userCreator.CreateTestUsers(NUM_USERS_RANGE)
- if valid == false {
- b.Fatal("Unable to create test users")
- }
-
- // Benchmark Start
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- for j := range users {
- if _, err := th.BasicClient.AddChannelMember(channel.Id, users[j].Id); err != nil {
- b.Fatal(err)
- }
- }
- }
-}
-
-// Is this benchmark failing? Raise your file ulimit! 2048 worked for me.
-func BenchmarkRemoveChannelMember(b *testing.B) {
- th := Setup().InitBasic()
-
- var (
- NUM_USERS = 140
- NUM_USERS_RANGE = utils.Range{NUM_USERS, NUM_USERS}
- )
-
- channel := &model.Channel{DisplayName: "Test Channel", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: th.BasicTeam.Id}
- channel = th.BasicClient.Must(th.BasicClient.CreateChannel(channel)).Data.(*model.Channel)
-
- userCreator := NewAutoUserCreator(th.BasicClient, th.BasicTeam)
- users, valid := userCreator.CreateTestUsers(NUM_USERS_RANGE)
- if valid == false {
- b.Fatal("Unable to create test users")
- }
-
- for i := range users {
- if _, err := th.BasicClient.AddChannelMember(channel.Id, users[i].Id); err != nil {
- b.Fatal(err)
- }
- }
-
- // Benchmark Start
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- for j := range users {
- if _, err := th.BasicClient.RemoveChannelMember(channel.Id, users[j].Id); err != nil {
- b.Fatal(err)
- }
- }
- }
-}
-
-func BenchmarkUpdateNotifyProps(b *testing.B) {
- th := Setup().InitBasic()
-
- var (
- NUM_CHANNELS_RANGE = utils.Range{NUM_CHANNELS, NUM_CHANNELS}
- )
-
- channelCreator := NewAutoChannelCreator(th.BasicClient, th.BasicTeam)
- channels, valid := channelCreator.CreateTestChannels(NUM_CHANNELS_RANGE)
- if valid == false {
- b.Fatal("Unable to create test channels")
- }
-
- data := make([]map[string]string, len(channels))
-
- for i := range data {
- newmap := map[string]string{
- "channel_id": channels[i].Id,
- "user_id": th.BasicUser.Id,
- "desktop": model.CHANNEL_NOTIFY_MENTION,
- "mark_unread": model.CHANNEL_MARK_UNREAD_MENTION,
- }
- data[i] = newmap
- }
-
- // Benchmark Start
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- for j := range channels {
- th.BasicClient.Must(th.BasicClient.UpdateNotifyProps(data[j]))
- }
- }
-}
diff --git a/api/file.go b/api/file.go
index dd99a8caf..9cf513ebf 100644
--- a/api/file.go
+++ b/api/file.go
@@ -16,6 +16,7 @@ import (
"io"
"io/ioutil"
"net/http"
+ "net/url"
"os"
"path/filepath"
"strconv"
@@ -57,17 +58,19 @@ const (
MaxImageSize = 6048 * 4032 // 24 megapixels, roughly 36MB as a raw image
)
-var fileInfoCache *utils.Cache = utils.NewLru(1000)
-
func InitFile() {
l4g.Debug(utils.T("api.file.init.debug"))
- BaseRoutes.Files.Handle("/upload", ApiUserRequired(uploadFile)).Methods("POST")
- BaseRoutes.Files.Handle("/get/{channel_id:[A-Za-z0-9]+}/{user_id:[A-Za-z0-9]+}/{filename:([A-Za-z0-9]+/)?.+(\\.[A-Za-z0-9]{3,})?}", ApiUserRequiredTrustRequester(getFile)).Methods("GET")
- BaseRoutes.Files.Handle("/get_info/{channel_id:[A-Za-z0-9]+}/{user_id:[A-Za-z0-9]+}/{filename:([A-Za-z0-9]+/)?.+(\\.[A-Za-z0-9]{3,})?}", ApiUserRequired(getFileInfo)).Methods("GET")
- BaseRoutes.Files.Handle("/get_public_link", ApiUserRequired(getPublicLink)).Methods("POST")
+ BaseRoutes.TeamFiles.Handle("/upload", ApiUserRequired(uploadFile)).Methods("POST")
+
+ BaseRoutes.NeedFile.Handle("/get", ApiUserRequiredTrustRequester(getFile)).Methods("GET")
+ BaseRoutes.NeedFile.Handle("/get_thumbnail", ApiUserRequiredTrustRequester(getFileThumbnail)).Methods("GET")
+ BaseRoutes.NeedFile.Handle("/get_preview", ApiUserRequiredTrustRequester(getFilePreview)).Methods("GET")
+ BaseRoutes.NeedFile.Handle("/get_info", ApiUserRequired(getFileInfo)).Methods("GET")
+ BaseRoutes.NeedFile.Handle("/get_public_link", ApiUserRequired(getPublicLink)).Methods("GET")
- BaseRoutes.Public.Handle("/files/get/{team_id:[A-Za-z0-9]+}/{channel_id:[A-Za-z0-9]+}/{user_id:[A-Za-z0-9]+}/{filename:([A-Za-z0-9]+/)?.+(\\.[A-Za-z0-9]{3,})?}", ApiAppHandlerTrustRequesterIndependent(getPublicFile)).Methods("GET")
+ BaseRoutes.Public.Handle("/files/{file_id:[A-Za-z0-9]+}/get", ApiAppHandlerTrustRequesterIndependent(getPublicFile)).Methods("GET")
+ BaseRoutes.Public.Handle("/files/get/{team_id:[A-Za-z0-9]+}/{channel_id:[A-Za-z0-9]+}/{user_id:[A-Za-z0-9]+}/{filename:([A-Za-z0-9]+/)?.+(\\.[A-Za-z0-9]{3,})?}", ApiAppHandlerTrustRequesterIndependent(getPublicFileOld)).Methods("GET")
}
func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) {
@@ -83,8 +86,7 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
- err := r.ParseMultipartForm(*utils.Cfg.FileSettings.MaxFileSize)
- if err != nil {
+ if err := r.ParseMultipartForm(*utils.Cfg.FileSettings.MaxFileSize); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
@@ -92,7 +94,6 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) {
m := r.MultipartForm
props := m.Value
-
if len(props["channel_id"]) == 0 {
c.SetInvalidParam("uploadFile", "channel_id")
return
@@ -103,91 +104,108 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
- files := m.File["files"]
+ if !HasPermissionToChannelContext(c, channelId, model.PERMISSION_UPLOAD_FILE) {
+ return
+ }
resStruct := &model.FileUploadResponse{
- Filenames: []string{},
+ FileInfos: []*model.FileInfo{},
ClientIds: []string{},
}
- imageNameList := []string{}
+ previewPathList := []string{}
+ thumbnailPathList := []string{}
imageDataList := [][]byte{}
- if !HasPermissionToChannelContext(c, channelId, model.PERMISSION_UPLOAD_FILE) {
- return
- }
-
- for i := range files {
- file, err := files[i].Open()
+ for i, fileHeader := range m.File["files"] {
+ file, fileErr := fileHeader.Open()
defer file.Close()
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ if fileErr != nil {
+ http.Error(w, fileErr.Error(), http.StatusInternalServerError)
return
}
buf := bytes.NewBuffer(nil)
io.Copy(buf, file)
+ data := buf.Bytes()
- filename := filepath.Base(files[i].Filename)
+ info, err := doUploadFile(c.TeamId, channelId, c.Session.UserId, fileHeader.Filename, data)
+ if err != nil {
+ c.Err = err
+ return
+ }
- uid := model.NewId()
+ if info.PreviewPath != "" || info.ThumbnailPath != "" {
+ previewPathList = append(previewPathList, info.PreviewPath)
+ thumbnailPathList = append(thumbnailPathList, info.ThumbnailPath)
+ imageDataList = append(imageDataList, data)
+ }
- if model.IsFileExtImage(filepath.Ext(files[i].Filename)) {
- imageNameList = append(imageNameList, uid+"/"+filename)
- imageDataList = append(imageDataList, buf.Bytes())
+ resStruct.FileInfos = append(resStruct.FileInfos, info)
- // Decode image config first to check dimensions before loading the whole thing into memory later on
- config, _, err := image.DecodeConfig(bytes.NewReader(buf.Bytes()))
- if err != nil {
- c.Err = model.NewLocAppError("uploadFile", "api.file.upload_file.image.app_error", nil, err.Error())
- return
- } else if config.Width*config.Height > MaxImageSize {
- c.Err = model.NewLocAppError("uploadFile", "api.file.upload_file.large_image.app_error", nil, c.T("api.file.file_upload.exceeds"))
- return
- }
+ if len(m.Value["client_ids"]) > 0 {
+ resStruct.ClientIds = append(resStruct.ClientIds, m.Value["client_ids"][i])
}
+ }
- path := "teams/" + c.TeamId + "/channels/" + channelId + "/users/" + c.Session.UserId + "/" + uid + "/" + filename
+ handleImages(previewPathList, thumbnailPathList, imageDataList)
- if err := WriteFile(buf.Bytes(), path); err != nil {
- c.Err = err
- return
- }
+ w.Write([]byte(resStruct.ToJson()))
+}
- encName := utils.UrlEncode(filename)
+func doUploadFile(teamId string, channelId string, userId string, rawFilename string, data []byte) (*model.FileInfo, *model.AppError) {
+ filename := filepath.Base(rawFilename)
- fileUrl := "/" + channelId + "/" + c.Session.UserId + "/" + uid + "/" + encName
- resStruct.Filenames = append(resStruct.Filenames, fileUrl)
+ info, err := model.GetInfoForBytes(filename, data)
+ if err != nil {
+ err.StatusCode = http.StatusBadRequest
+ return nil, err
}
- for _, clientId := range props["client_ids"] {
- resStruct.ClientIds = append(resStruct.ClientIds, clientId)
+ info.Id = model.NewId()
+ info.CreatorId = userId
+
+ pathPrefix := "teams/" + teamId + "/channels/" + channelId + "/users/" + userId + "/" + info.Id + "/"
+ info.Path = pathPrefix + filename
+
+ if info.IsImage() {
+ // Check dimensions before loading the whole thing into memory later on
+ if info.Width*info.Height > MaxImageSize {
+ err := model.NewLocAppError("uploadFile", "api.file.upload_file.large_image.app_error", nil, "")
+ err.StatusCode = http.StatusBadRequest
+ return nil, err
+ }
+
+ nameWithoutExtension := filename[:strings.LastIndex(filename, ".")]
+ info.PreviewPath = pathPrefix + nameWithoutExtension + "_preview.jpg"
+ info.ThumbnailPath = pathPrefix + nameWithoutExtension + "_thumb.jpg"
}
- go handleImages(imageNameList, imageDataList, c.TeamId, channelId, c.Session.UserId)
+ if err := WriteFile(data, info.Path); err != nil {
+ return nil, err
+ }
- w.Write([]byte(resStruct.ToJson()))
-}
+ if result := <-Srv.Store.FileInfo().Save(info); result.Err != nil {
+ return nil, result.Err
+ }
-func handleImages(filenames []string, fileData [][]byte, teamId, channelId, userId string) {
- dest := "teams/" + teamId + "/channels/" + channelId + "/users/" + userId + "/"
+ return info, nil
+}
- for i, filename := range filenames {
- name := filename[:strings.LastIndex(filename, ".")]
+func handleImages(previewPathList []string, thumbnailPathList []string, fileData [][]byte) {
+ for i := range fileData {
go func() {
// Decode image bytes into Image object
img, imgType, err := image.Decode(bytes.NewReader(fileData[i]))
if err != nil {
- l4g.Error(utils.T("api.file.handle_images_forget.decode.error"), channelId, userId, filename, err)
+ l4g.Error(utils.T("api.file.handle_images_forget.decode.error"), err)
return
}
width := img.Bounds().Dx()
height := img.Bounds().Dy()
- // Get the image's orientation and ignore any errors since not all images will have orientation data
- orientation, _ := getImageOrientation(fileData[i])
-
+ // Fill in the background of a potentially-transparent png file as white
if imgType == "png" {
dst := image.NewRGBA(img.Bounds())
draw.Draw(dst, dst.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
@@ -195,6 +213,9 @@ func handleImages(filenames []string, fileData [][]byte, teamId, channelId, user
img = dst
}
+ // Flip the image to be upright
+ orientation, _ := getImageOrientation(fileData[i])
+
switch orientation {
case UprightMirrored:
img = imaging.FlipH(img)
@@ -212,57 +233,8 @@ func handleImages(filenames []string, fileData [][]byte, teamId, channelId, user
img = imaging.Rotate90(img)
}
- // Create thumbnail
- go func() {
- thumbWidth := float64(utils.Cfg.FileSettings.ThumbnailWidth)
- thumbHeight := float64(utils.Cfg.FileSettings.ThumbnailHeight)
- imgWidth := float64(width)
- imgHeight := float64(height)
-
- var thumbnail image.Image
- if imgHeight < thumbHeight && imgWidth < thumbWidth {
- thumbnail = img
- } else if imgHeight/imgWidth < thumbHeight/thumbWidth {
- thumbnail = imaging.Resize(img, 0, utils.Cfg.FileSettings.ThumbnailHeight, imaging.Lanczos)
- } else {
- thumbnail = imaging.Resize(img, utils.Cfg.FileSettings.ThumbnailWidth, 0, imaging.Lanczos)
- }
-
- buf := new(bytes.Buffer)
- err = jpeg.Encode(buf, thumbnail, &jpeg.Options{Quality: 90})
- if err != nil {
- l4g.Error(utils.T("api.file.handle_images_forget.encode_jpeg.error"), channelId, userId, filename, err)
- return
- }
-
- if err := WriteFile(buf.Bytes(), dest+name+"_thumb.jpg"); err != nil {
- l4g.Error(utils.T("api.file.handle_images_forget.upload_thumb.error"), channelId, userId, filename, err)
- return
- }
- }()
-
- // Create preview
- go func() {
- var preview image.Image
- if width > int(utils.Cfg.FileSettings.PreviewWidth) {
- preview = imaging.Resize(img, utils.Cfg.FileSettings.PreviewWidth, utils.Cfg.FileSettings.PreviewHeight, imaging.Lanczos)
- } else {
- preview = img
- }
-
- buf := new(bytes.Buffer)
-
- err = jpeg.Encode(buf, preview, &jpeg.Options{Quality: 90})
- if err != nil {
- l4g.Error(utils.T("api.file.handle_images_forget.encode_preview.error"), channelId, userId, filename, err)
- return
- }
-
- if err := WriteFile(buf.Bytes(), dest+name+"_preview.jpg"); err != nil {
- l4g.Error(utils.T("api.file.handle_images_forget.upload_preview.error"), channelId, userId, filename, err)
- return
- }
- }()
+ go generateThumbnailImage(img, thumbnailPathList[i], width, height)
+ go generatePreviewImage(img, previewPathList[i], width)
}()
}
}
@@ -284,105 +256,143 @@ func getImageOrientation(imageData []byte) (int, error) {
}
}
-type ImageGetResult struct {
- Error error
- ImageData []byte
-}
+func generateThumbnailImage(img image.Image, thumbnailPath string, width int, height int) {
+ thumbWidth := float64(utils.Cfg.FileSettings.ThumbnailWidth)
+ thumbHeight := float64(utils.Cfg.FileSettings.ThumbnailHeight)
+ imgWidth := float64(width)
+ imgHeight := float64(height)
+
+ var thumbnail image.Image
+ if imgHeight < thumbHeight && imgWidth < thumbWidth {
+ thumbnail = img
+ } else if imgHeight/imgWidth < thumbHeight/thumbWidth {
+ thumbnail = imaging.Resize(img, 0, utils.Cfg.FileSettings.ThumbnailHeight, imaging.Lanczos)
+ } else {
+ thumbnail = imaging.Resize(img, utils.Cfg.FileSettings.ThumbnailWidth, 0, imaging.Lanczos)
+ }
-func getFileInfo(c *Context, w http.ResponseWriter, r *http.Request) {
- if len(utils.Cfg.FileSettings.DriverName) == 0 {
- c.Err = model.NewLocAppError("uploadFile", "api.file.upload_file.storage.app_error", nil, "")
- c.Err.StatusCode = http.StatusNotImplemented
+ buf := new(bytes.Buffer)
+ if err := jpeg.Encode(buf, thumbnail, &jpeg.Options{Quality: 90}); err != nil {
+ l4g.Error(utils.T("api.file.handle_images_forget.encode_jpeg.error"), thumbnailPath, err)
return
}
- params := mux.Vars(r)
-
- channelId := params["channel_id"]
- if len(channelId) != 26 {
- c.SetInvalidParam("getFileInfo", "channel_id")
+ if err := WriteFile(buf.Bytes(), thumbnailPath); err != nil {
+ l4g.Error(utils.T("api.file.handle_images_forget.upload_thumb.error"), thumbnailPath, err)
return
}
+}
- userId := params["user_id"]
- if len(userId) != 26 {
- c.SetInvalidParam("getFileInfo", "user_id")
- return
+func generatePreviewImage(img image.Image, previewPath string, width int) {
+ var preview image.Image
+ if width > int(utils.Cfg.FileSettings.PreviewWidth) {
+ preview = imaging.Resize(img, utils.Cfg.FileSettings.PreviewWidth, utils.Cfg.FileSettings.PreviewHeight, imaging.Lanczos)
+ } else {
+ preview = img
}
- filename := params["filename"]
- if len(filename) == 0 {
- c.SetInvalidParam("getFileInfo", "filename")
+ buf := new(bytes.Buffer)
+
+ if err := jpeg.Encode(buf, preview, &jpeg.Options{Quality: 90}); err != nil {
+ l4g.Error(utils.T("api.file.handle_images_forget.encode_preview.error"), previewPath, err)
return
}
- if !HasPermissionToChannelContext(c, channelId, model.PERMISSION_READ_CHANNEL) {
+ if err := WriteFile(buf.Bytes(), previewPath); err != nil {
+ l4g.Error(utils.T("api.file.handle_images_forget.upload_preview.error"), previewPath, err)
return
}
+}
- path := "teams/" + c.TeamId + "/channels/" + channelId + "/users/" + userId + "/" + filename
- var info *model.FileInfo
+func getFile(c *Context, w http.ResponseWriter, r *http.Request) {
+ info, err := getFileInfoForRequest(c, r, true)
+ if err != nil {
+ c.Err = err
+ return
+ }
- if cached, ok := fileInfoCache.Get(path); ok {
- info = cached.(*model.FileInfo)
- } else {
- fileData := make(chan []byte)
- go readFile(path, fileData)
+ if data, err := ReadFile(info.Path); err != nil {
+ c.Err = err
+ c.Err.StatusCode = http.StatusNotFound
+ } else if err := writeFileResponse(info.Name, data, w, r); err != nil {
+ c.Err = err
+ return
+ }
+}
- newInfo, err := model.GetInfoForBytes(filename, <-fileData)
- if err != nil {
- c.Err = err
- return
- } else {
- fileInfoCache.Add(path, newInfo)
- info = newInfo
- }
+func getFileThumbnail(c *Context, w http.ResponseWriter, r *http.Request) {
+ info, err := getFileInfoForRequest(c, r, true)
+ if err != nil {
+ c.Err = err
+ return
}
- w.Header().Set("Cache-Control", "max-age=2592000, public")
+ if info.ThumbnailPath == "" {
+ c.Err = model.NewLocAppError("getFileThumbnail", "api.file.get_file_thumbnail.no_thumbnail.app_error", nil, "file_id="+info.Id)
+ c.Err.StatusCode = http.StatusBadRequest
+ return
+ }
- w.Write([]byte(info.ToJson()))
+ if data, err := ReadFile(info.ThumbnailPath); err != nil {
+ c.Err = err
+ c.Err.StatusCode = http.StatusNotFound
+ } else if err := writeFileResponse(info.Name, data, w, r); err != nil {
+ c.Err = err
+ return
+ }
}
-func getFile(c *Context, w http.ResponseWriter, r *http.Request) {
- params := mux.Vars(r)
-
- teamId := c.TeamId
- channelId := params["channel_id"]
- userId := params["user_id"]
- filename := params["filename"]
+func getFilePreview(c *Context, w http.ResponseWriter, r *http.Request) {
+ info, err := getFileInfoForRequest(c, r, true)
+ if err != nil {
+ c.Err = err
+ return
+ }
- if !HasPermissionToChannelContext(c, channelId, model.PERMISSION_READ_CHANNEL) {
+ if info.PreviewPath == "" {
+ c.Err = model.NewLocAppError("getFilePreview", "api.file.get_file_preview.no_preview.app_error", nil, "file_id="+info.Id)
+ c.Err.StatusCode = http.StatusBadRequest
return
}
- if err, bytes := getFileData(teamId, channelId, userId, filename); err != nil {
+ if data, err := ReadFile(info.PreviewPath); err != nil {
c.Err = err
- return
- } else if err := writeFileResponse(filename, bytes, w, r); err != nil {
+ c.Err.StatusCode = http.StatusNotFound
+ } else if err := writeFileResponse(info.Name, data, w, r); err != nil {
c.Err = err
return
}
}
-func getPublicFile(c *Context, w http.ResponseWriter, r *http.Request) {
- params := mux.Vars(r)
+func getFileInfo(c *Context, w http.ResponseWriter, r *http.Request) {
+ info, err := getFileInfoForRequest(c, r, true)
+ if err != nil {
+ c.Err = err
+ return
+ }
- teamId := params["team_id"]
- channelId := params["channel_id"]
- userId := params["user_id"]
- filename := params["filename"]
+ w.Header().Set("Cache-Control", "max-age=2592000, public")
- hash := r.URL.Query().Get("h")
+ w.Write([]byte(info.ToJson()))
+}
+func getPublicFile(c *Context, w http.ResponseWriter, r *http.Request) {
if !utils.Cfg.FileSettings.EnablePublicLink {
c.Err = model.NewLocAppError("getPublicFile", "api.file.get_file.public_disabled.app_error", nil, "")
c.Err.StatusCode = http.StatusNotImplemented
return
}
+ info, err := getFileInfoForRequest(c, r, false)
+ if err != nil {
+ c.Err = err
+ return
+ }
+
+ hash := r.URL.Query().Get("h")
+
if len(hash) > 0 {
- correctHash := generatePublicLinkHash(filename, *utils.Cfg.FileSettings.PublicLinkSalt)
+ correctHash := generatePublicLinkHash(info.Id, *utils.Cfg.FileSettings.PublicLinkSalt)
if hash != correctHash {
c.Err = model.NewLocAppError("getPublicFile", "api.file.get_file.public_invalid.app_error", nil, "")
@@ -395,49 +405,110 @@ func getPublicFile(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
- if err, bytes := getFileData(teamId, channelId, userId, filename); err != nil {
+ if data, err := ReadFile(info.Path); err != nil {
c.Err = err
- return
- } else if err := writeFileResponse(filename, bytes, w, r); err != nil {
+ c.Err.StatusCode = http.StatusNotFound
+ } else if err := writeFileResponse(info.Name, data, w, r); err != nil {
c.Err = err
return
}
}
-func getFileData(teamId string, channelId string, userId string, filename string) (*model.AppError, []byte) {
+func getFileInfoForRequest(c *Context, r *http.Request, requireFileVisible bool) (*model.FileInfo, *model.AppError) {
if len(utils.Cfg.FileSettings.DriverName) == 0 {
- err := model.NewLocAppError("getFileData", "api.file.upload_file.storage.app_error", nil, "")
+ err := model.NewLocAppError("getFileInfoForRequest", "api.file.get_file_info_for_request.storage.app_error", nil, "")
err.StatusCode = http.StatusNotImplemented
- return err, nil
+ return nil, err
}
- if len(teamId) != 26 {
- return NewInvalidParamError("getFileData", "team_id"), nil
+ params := mux.Vars(r)
+
+ fileId := params["file_id"]
+ if len(fileId) != 26 {
+ return nil, NewInvalidParamError("getFileInfoForRequest", "file_id")
}
- if len(channelId) != 26 {
- return NewInvalidParamError("getFileData", "channel_id"), nil
+ var info *model.FileInfo
+ if result := <-Srv.Store.FileInfo().Get(fileId); result.Err != nil {
+ return nil, result.Err
+ } else {
+ info = result.Data.(*model.FileInfo)
}
- if len(userId) != 26 {
- return NewInvalidParamError("getFileData", "user_id"), nil
+ // only let users access files visible in a channel, unless they're the one who uploaded the file
+ if info.CreatorId != c.Session.UserId {
+ if len(info.PostId) == 0 {
+ err := model.NewLocAppError("getFileInfoForRequest", "api.file.get_file_info_for_request.no_post.app_error", nil, "file_id="+fileId)
+ err.StatusCode = http.StatusBadRequest
+ return nil, err
+ }
+
+ if requireFileVisible {
+ if !HasPermissionToChannelByPostContext(c, info.PostId, model.PERMISSION_READ_CHANNEL) {
+ return nil, c.Err
+ }
+ }
}
- if len(filename) == 0 {
- return NewInvalidParamError("getFileData", "filename"), nil
+ return info, nil
+}
+
+func getPublicFileOld(c *Context, w http.ResponseWriter, r *http.Request) {
+ if len(utils.Cfg.FileSettings.DriverName) == 0 {
+ c.Err = model.NewLocAppError("getPublicFile", "api.file.get_public_file_old.storage.app_error", nil, "")
+ c.Err.StatusCode = http.StatusNotImplemented
+ return
+ } else if !utils.Cfg.FileSettings.EnablePublicLink {
+ c.Err = model.NewLocAppError("getPublicFile", "api.file.get_file.public_disabled.app_error", nil, "")
+ c.Err.StatusCode = http.StatusNotImplemented
+ return
}
- path := "teams/" + teamId + "/channels/" + channelId + "/users/" + userId + "/" + filename
+ params := mux.Vars(r)
- fileChan := make(chan []byte)
- go readFile(path, fileChan)
+ teamId := params["team_id"]
+ channelId := params["channel_id"]
+ userId := params["user_id"]
+ filename := params["filename"]
- if bytes := <-fileChan; bytes == nil {
- err := model.NewLocAppError("writeFileResponse", "api.file.get_file.not_found.app_error", nil, "path="+path)
- err.StatusCode = http.StatusNotFound
- return err, nil
+ hash := r.URL.Query().Get("h")
+
+ if len(hash) > 0 {
+ correctHash := generatePublicLinkHash(filename, *utils.Cfg.FileSettings.PublicLinkSalt)
+
+ if hash != correctHash {
+ c.Err = model.NewLocAppError("getPublicFile", "api.file.get_file.public_invalid.app_error", nil, "")
+ c.Err.StatusCode = http.StatusBadRequest
+ return
+ }
} else {
- return nil, bytes
+ c.Err = model.NewLocAppError("getPublicFile", "api.file.get_file.public_invalid.app_error", nil, "")
+ c.Err.StatusCode = http.StatusBadRequest
+ return
+ }
+
+ path := "teams/" + teamId + "/channels/" + channelId + "/users/" + userId + "/" + filename
+
+ var info *model.FileInfo
+ if result := <-Srv.Store.FileInfo().GetByPath(path); result.Err != nil {
+ c.Err = result.Err
+ return
+ } else {
+ info = result.Data.(*model.FileInfo)
+ }
+
+ if len(info.PostId) == 0 {
+ c.Err = model.NewLocAppError("getPublicFileOld", "api.file.get_public_file_old.no_post.app_error", nil, "file_id="+info.Id)
+ c.Err.StatusCode = http.StatusBadRequest
+ return
+ }
+
+ if data, err := ReadFile(info.Path); err != nil {
+ c.Err = err
+ c.Err.StatusCode = http.StatusNotFound
+ } else if err := writeFileResponse(info.Name, data, w, r); err != nil {
+ c.Err = err
+ return
}
}
@@ -450,9 +521,7 @@ func writeFileResponse(filename string, bytes []byte, w http.ResponseWriter, r *
ua := user_agent.New(r.UserAgent())
bname, _ := ua.Browser()
- parts := strings.Split(filename, "/")
- filePart := strings.Split(parts[len(parts)-1], "?")[0]
- w.Header().Set("Content-Disposition", "attachment;filename=\""+filePart+"\"")
+ w.Header().Set("Content-Disposition", "attachment;filename=\""+filename+"\"")
if bname == "Edge" || bname == "Internet Explorer" || bname == "Safari" {
w.Header().Set("Content-Type", "application/octet-stream")
@@ -467,71 +536,183 @@ func writeFileResponse(filename string, bytes []byte, w http.ResponseWriter, r *
return nil
}
-func readFile(path string, fileData chan []byte) {
- data, getErr := ReadFile(path)
- if getErr != nil {
- l4g.Error(getErr)
- fileData <- nil
- } else {
- fileData <- data
- }
-}
-
func getPublicLink(c *Context, w http.ResponseWriter, r *http.Request) {
- if len(utils.Cfg.FileSettings.DriverName) == 0 {
- c.Err = model.NewLocAppError("uploadFile", "api.file.upload_file.storage.app_error", nil, "")
- c.Err.StatusCode = http.StatusNotImplemented
- return
- }
-
if !utils.Cfg.FileSettings.EnablePublicLink {
c.Err = model.NewLocAppError("getPublicLink", "api.file.get_public_link.disabled.app_error", nil, "")
c.Err.StatusCode = http.StatusNotImplemented
return
}
- props := model.MapFromJson(r.Body)
-
- filename := props["filename"]
- if len(filename) == 0 {
- c.SetInvalidParam("getPublicLink", "filename")
+ info, err := getFileInfoForRequest(c, r, true)
+ if err != nil {
+ c.Err = err
return
}
- matches := model.PartialUrlRegex.FindAllStringSubmatch(filename, -1)
- if len(matches) == 0 || len(matches[0]) < 4 {
- c.SetInvalidParam("getPublicLink", "filename")
+ if len(info.PostId) == 0 {
+ c.Err = model.NewLocAppError("getPublicLink", "api.file.get_public_link.no_post.app_error", nil, "file_id="+info.Id)
+ c.Err.StatusCode = http.StatusBadRequest
return
}
- channelId := matches[0][1]
- userId := matches[0][2]
- filename = matches[0][3]
+ w.Write([]byte(model.StringToJson(generatePublicLink(c.GetSiteURL(), info))))
+}
- if !HasPermissionToChannelContext(c, channelId, model.PERMISSION_GET_PUBLIC_LINK) {
- return
+func generatePublicLink(siteURL string, info *model.FileInfo) string {
+ hash := generatePublicLinkHash(info.Id, *utils.Cfg.FileSettings.PublicLinkSalt)
+ return fmt.Sprintf("%s%s/public/files/%v/get?h=%s", siteURL, model.API_URL_SUFFIX, info.Id, hash)
+}
+
+func generatePublicLinkHash(fileId, salt string) string {
+ hash := sha256.New()
+ hash.Write([]byte(salt))
+ hash.Write([]byte(fileId))
+
+ return base64.RawURLEncoding.EncodeToString(hash.Sum(nil))
+}
+
+// Creates and stores FileInfos for a post created before the FileInfos table existed.
+func migrateFilenamesToFileInfos(post *model.Post) []*model.FileInfo {
+ if len(post.Filenames) == 0 {
+ l4g.Warn(utils.T("api.file.migrate_filenames_to_file_infos.no_filenames.warn"), post.Id)
+ return []*model.FileInfo{}
+ }
+
+ cchan := Srv.Store.Channel().Get(post.ChannelId)
+
+ // There's a weird bug that rarely happens where a post ends up with duplicate Filenames so remove those
+ filenames := utils.RemoveDuplicatesFromStringArray(post.Filenames)
+
+ var channel *model.Channel
+ if result := <-cchan; result.Err != nil {
+ l4g.Error(utils.T("api.file.migrate_filenames_to_file_infos.channel.app_error"), post.Id, post.ChannelId, result.Err)
+ return []*model.FileInfo{}
+ } else {
+ channel = result.Data.(*model.Channel)
+ }
+
+ // Find the team that was used to make this post since its part of the file path that isn't saved in the Filename
+ var teamId string
+ if channel.TeamId == "" {
+ // This post was made in a cross-team DM channel so we need to find where its files were saved
+ teamId = findTeamIdForFilename(post, filenames[0])
+ } else {
+ teamId = channel.TeamId
+ }
+
+ // Create FileInfo objects for this post
+ infos := make([]*model.FileInfo, 0, len(filenames))
+ fileIds := make([]string, 0, len(filenames))
+ if teamId == "" {
+ l4g.Error(utils.T("api.file.migrate_filenames_to_file_infos.team_id.error"), post.Id, filenames)
+ } else {
+ for _, filename := range filenames {
+ info := getInfoForFilename(post, teamId, filename)
+ if info == nil {
+ continue
+ }
+
+ if result := <-Srv.Store.FileInfo().Save(info); result.Err != nil {
+ l4g.Error(utils.T("api.file.migrate_filenames_to_file_infos.save_file_info.app_error"), post.Id, info.Id, filename, result.Err)
+ continue
+ }
+
+ fileIds = append(fileIds, info.Id)
+ infos = append(infos, info)
+ }
}
- url := generatePublicLink(c.GetSiteURL(), c.TeamId, channelId, userId, filename)
+ // Copy and save the updated post
+ newPost := &model.Post{}
+ *newPost = *post
- w.Write([]byte(model.StringToJson(url)))
+ newPost.Filenames = []string{}
+ newPost.FileIds = fileIds
+
+ // Update Posts to clear Filenames and set FileIds
+ if result := <-Srv.Store.Post().Update(newPost, post); result.Err != nil {
+ l4g.Error(utils.T("api.file.migrate_filenames_to_file_infos.save_post.app_error"), post.Id, newPost.FileIds, post.Filenames, result.Err)
+ return []*model.FileInfo{}
+ } else {
+ return infos
+ }
}
-func generatePublicLink(siteURL, teamId, channelId, userId, filename string) string {
- hash := generatePublicLinkHash(filename, *utils.Cfg.FileSettings.PublicLinkSalt)
- return fmt.Sprintf("%s%s/public/files/get/%s/%s/%s/%s?h=%s", siteURL, model.API_URL_SUFFIX, teamId, channelId, userId, filename, hash)
+func findTeamIdForFilename(post *model.Post, filename string) string {
+ split := strings.SplitN(filename, "/", 5)
+ id := split[3]
+ name, _ := url.QueryUnescape(split[4])
+
+ // This post is in a direct channel so we need to figure out what team the files are stored under.
+ if result := <-Srv.Store.Team().GetTeamsByUserId(post.UserId); result.Err != nil {
+ l4g.Error(utils.T("api.file.migrate_filenames_to_file_infos.teams.app_error"), post.Id, result.Err)
+ } else if teams := result.Data.([]*model.Team); len(teams) == 1 {
+ // The user has only one team so the post must've been sent from it
+ return teams[0].Id
+ } else {
+ for _, team := range teams {
+ path := fmt.Sprintf("teams/%s/channels/%s/users/%s/%s/%s", team.Id, post.ChannelId, post.UserId, id, name)
+ if _, err := ReadFile(path); err == nil {
+ // Found the team that this file was posted from
+ return team.Id
+ }
+ }
+ }
+
+ return ""
}
-func generatePublicLinkHash(filename, salt string) string {
- hash := sha256.New()
- hash.Write([]byte(salt))
- hash.Write([]byte(filename))
+func getInfoForFilename(post *model.Post, teamId string, filename string) *model.FileInfo {
+ // Find the path from the Filename of the form /{channelId}/{userId}/{uid}/{nameWithExtension}
+ split := strings.SplitN(filename, "/", 5)
+ if len(split) < 5 {
+ l4g.Error(utils.T("api.file.migrate_filenames_to_file_infos.unexpected_filename.error"), post.Id, filename)
+ return nil
+ }
- return base64.RawURLEncoding.EncodeToString(hash.Sum(nil))
+ channelId := split[1]
+ userId := split[2]
+ oldId := split[3]
+ name, _ := url.QueryUnescape(split[4])
+
+ if split[0] != "" || split[1] != post.ChannelId || split[2] != post.UserId || strings.Contains(split[4], "/") {
+ l4g.Warn(utils.T("api.file.migrate_filenames_to_file_infos.mismatched_filename.warn"), post.Id, post.ChannelId, post.UserId, filename)
+ }
+
+ pathPrefix := fmt.Sprintf("teams/%s/channels/%s/users/%s/%s/", teamId, channelId, userId, oldId)
+ path := pathPrefix + name
+
+ // Open the file and populate the fields of the FileInfo
+ var info *model.FileInfo
+ if data, err := ReadFile(path); err != nil {
+ l4g.Error(utils.T("api.file.migrate_filenames_to_file_infos.file_not_found.error"), post.Id, filename, path, err)
+ return nil
+ } else {
+ var err *model.AppError
+ info, err = model.GetInfoForBytes(name, data)
+ if err != nil {
+ l4g.Warn(utils.T("api.file.migrate_filenames_to_file_infos.info.app_error"), post.Id, filename, err)
+ }
+ }
+
+ // Generate a new ID because with the old system, you could very rarely get multiple posts referencing the same file
+ info.Id = model.NewId()
+ info.CreatorId = post.UserId
+ info.PostId = post.Id
+ info.CreateAt = post.CreateAt
+ info.UpdateAt = post.UpdateAt
+ info.Path = path
+
+ if info.IsImage() {
+ nameWithoutExtension := name[:strings.LastIndex(name, ".")]
+ info.PreviewPath = pathPrefix + nameWithoutExtension + "_preview.jpg"
+ info.ThumbnailPath = pathPrefix + nameWithoutExtension + "_thumb.jpg"
+ }
+
+ return info
}
func WriteFile(f []byte, path string) *model.AppError {
-
if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_S3 {
var auth aws.Auth
auth.AccessKey = utils.Cfg.FileSettings.AmazonS3AccessKeyId
@@ -556,7 +737,7 @@ func WriteFile(f []byte, path string) *model.AppError {
return model.NewLocAppError("WriteFile", "api.file.write_file.s3.app_error", nil, err.Error())
}
} else if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_LOCAL {
- if err := WriteFileLocally(f, utils.Cfg.FileSettings.Directory+path); err != nil {
+ if err := writeFileLocally(f, utils.Cfg.FileSettings.Directory+path); err != nil {
return err
}
} else {
@@ -568,9 +749,7 @@ func WriteFile(f []byte, path string) *model.AppError {
func MoveFile(oldPath, newPath string) *model.AppError {
if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_S3 {
- fileData := make(chan []byte)
- go readFile(oldPath, fileData)
- fileBytes := <-fileData
+ fileBytes, _ := ReadFile(oldPath)
if fileBytes == nil {
return model.NewLocAppError("moveFile", "api.file.move_file.get_from_s3.app_error", nil, "")
@@ -606,7 +785,7 @@ func MoveFile(oldPath, newPath string) *model.AppError {
return nil
}
-func WriteFileLocally(f []byte, path string) *model.AppError {
+func writeFileLocally(f []byte, path string) *model.AppError {
if err := os.MkdirAll(filepath.Dir(path), 0774); err != nil {
directory, _ := filepath.Abs(filepath.Dir(path))
return model.NewLocAppError("WriteFile", "api.file.write_file_locally.create_dir.app_error", nil, "directory="+directory+", err="+err.Error())
@@ -620,7 +799,6 @@ func WriteFileLocally(f []byte, path string) *model.AppError {
}
func ReadFile(path string) ([]byte, *model.AppError) {
-
if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_S3 {
var auth aws.Auth
auth.AccessKey = utils.Cfg.FileSettings.AmazonS3AccessKeyId
@@ -668,7 +846,6 @@ func openFileWriteStream(path string) (io.Writer, *model.AppError) {
fileHandle.Chmod(0644)
return fileHandle, nil
}
-
}
return nil, model.NewLocAppError("openFileWriteStream", "api.file.open_file_write_stream.configured.app_error", nil, "")
diff --git a/api/file_benchmark_test.go b/api/file_benchmark_test.go
deleted file mode 100644
index 0e0fc105b..000000000
--- a/api/file_benchmark_test.go
+++ /dev/null
@@ -1,70 +0,0 @@
-// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
-// See License.txt for license information.
-
-package api
-
-import (
- "github.com/mattermost/platform/utils"
- "testing"
- "time"
-)
-
-func BenchmarkUploadFile(b *testing.B) {
- th := Setup().InitBasic()
- Client := th.BasicClient
- channel := th.BasicChannel
-
- testPoster := NewAutoPostCreator(Client, channel.Id)
-
- // Benchmark Start
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- testPoster.UploadTestFile()
- }
-}
-
-func BenchmarkGetFile(b *testing.B) {
- th := Setup().InitBasic()
- Client := th.BasicClient
- channel := th.BasicChannel
-
- testPoster := NewAutoPostCreator(Client, channel.Id)
- filenames, err := testPoster.UploadTestFile()
- if err == false {
- b.Fatal("Unable to upload file for benchmark")
- }
-
- // wait a bit for files to ready
- time.Sleep(5 * time.Second)
-
- // Benchmark Start
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- if _, downErr := Client.GetFile(filenames[0]+"?h="+generatePublicLinkHash(filenames[0], *utils.Cfg.FileSettings.PublicLinkSalt), true); downErr != nil {
- b.Fatal(downErr)
- }
- }
-}
-
-func BenchmarkGetPublicLink(b *testing.B) {
- th := Setup().InitBasic()
- Client := th.BasicClient
- channel := th.BasicChannel
-
- testPoster := NewAutoPostCreator(Client, channel.Id)
- filenames, err := testPoster.UploadTestFile()
- if err == false {
- b.Fatal("Unable to upload file for benchmark")
- }
-
- // wait a bit for files to ready
- time.Sleep(5 * time.Second)
-
- // Benchmark Start
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- if _, downErr := Client.GetPublicLink(filenames[0]); downErr != nil {
- b.Fatal(downErr)
- }
- }
-}
diff --git a/api/file_test.go b/api/file_test.go
index 764f326cd..ded866ab6 100644
--- a/api/file_test.go
+++ b/api/file_test.go
@@ -5,14 +5,14 @@ package api
import (
"bytes"
- "encoding/base64"
"fmt"
"github.com/goamz/goamz/aws"
"github.com/goamz/goamz/s3"
"github.com/mattermost/platform/model"
+ "github.com/mattermost/platform/store"
"github.com/mattermost/platform/utils"
"io"
- "mime/multipart"
+ "io/ioutil"
"net/http"
"os"
"strings"
@@ -22,373 +22,560 @@ import (
func TestUploadFile(t *testing.T) {
th := Setup().InitBasic()
+
+ if utils.Cfg.FileSettings.DriverName == "" {
+ t.Logf("skipping because no file driver is enabled")
+ return
+ }
+
Client := th.BasicClient
team := th.BasicTeam
user := th.BasicUser
channel := th.BasicChannel
- body := &bytes.Buffer{}
- writer := multipart.NewWriter(body)
- part, err := writer.CreateFormFile("files", "../test.png")
- if err != nil {
+ var uploadInfo *model.FileInfo
+ if data, err := readTestFile("test.png"); err != nil {
+ t.Fatal(err)
+ } else if resp, err := Client.UploadPostAttachment(data, channel.Id, "test.png"); err != nil {
t.Fatal(err)
+ } else if len(resp.FileInfos) != 1 {
+ t.Fatal("should've returned a single file infos")
+ } else {
+ uploadInfo = resp.FileInfos[0]
}
- path := utils.FindDir("tests")
- file, err := os.Open(path + "/test.png")
- if err != nil {
+ // 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 := <-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")
+ }
+
+ // This also makes sure that the relative path provided above is sanitized out
+ expectedPath := fmt.Sprintf("teams/%v/channels/%v/users/%v/%v/test.png", 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("teams/%v/channels/%v/users/%v/%v/test_thumb.jpg", 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("teams/%v/channels/%v/users/%v/%v/test_preview.jpg", 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 := cleanupTestFile(info); err != nil {
t.Fatal(err)
}
- defer file.Close()
+}
- _, err = io.Copy(part, file)
- if err != nil {
+func TestGetFileInfo(t *testing.T) {
+ th := Setup().InitBasic()
+
+ if utils.Cfg.FileSettings.DriverName == "" {
+ t.Skip("skipping because no file driver is enabled")
+ }
+
+ Client := th.BasicClient
+ user := th.BasicUser
+ channel := th.BasicChannel
+
+ var fileId string
+ if data, err := readTestFile("test.png"); err != nil {
t.Fatal(err)
+ } else {
+ fileId = Client.MustGeneric(Client.UploadPostAttachment(data, channel.Id, "test.png")).(*model.FileUploadResponse).FileInfos[0].Id
}
- field, err := writer.CreateFormField("channel_id")
+ info, err := Client.GetFileInfo(fileId)
if err != nil {
t.Fatal(err)
+ } else if info.Id != fileId {
+ t.Fatal("got incorrect file")
+ } 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 shouldn't have been returned to client")
+ } else if info.ThumbnailPath != "" {
+ t.Fatal("file thumbnail path shouldn't have been returned to client")
+ } else if info.PreviewPath != "" {
+ t.Fatal("file preview path shouldn't have been returned to client")
+ } else if info.MimeType != "image/png" {
+ t.Fatal("mime type should've been image/png")
+ }
+
+ // Wait a bit for files to ready
+ time.Sleep(2 * time.Second)
+
+ // Other user shouldn't be able to get file info for this file before it's attached to a post
+ th.LoginBasic2()
+
+ if _, err := Client.GetFileInfo(fileId); err == nil {
+ t.Fatal("other user shouldn't be able to get file info before it's attached to a post")
}
- _, err = field.Write([]byte(channel.Id))
- if err != nil {
+ // Hacky way to assign file to a post (usually would be done by CreatePost call)
+ store.Must(Srv.Store.FileInfo().AttachToPost(fileId, th.BasicPost.Id))
+
+ // Other user shouldn't be able to get file info for this file if they're not in the channel for it
+ if _, err := Client.GetFileInfo(fileId); err == nil {
+ t.Fatal("other user shouldn't be able to get file info when not in channel")
+ }
+
+ Client.Must(Client.JoinChannel(channel.Id))
+
+ // Other user should now be able to get file info
+ if info2, err := Client.GetFileInfo(fileId); err != nil {
t.Fatal(err)
+ } else if info2.Id != fileId {
+ t.Fatal("other user got incorrect file")
}
- err = writer.Close()
- if err != nil {
+ if err := cleanupTestFile(store.Must(Srv.Store.FileInfo().Get(fileId)).(*model.FileInfo)); err != nil {
t.Fatal(err)
}
+}
- resp, appErr := Client.UploadPostAttachment(body.Bytes(), writer.FormDataContentType())
- if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_S3 {
- if appErr != nil {
- t.Fatal(appErr)
- }
+func TestGetFile(t *testing.T) {
+ th := Setup().InitBasic()
- filenames := strings.Split(resp.Data.(*model.FileUploadResponse).Filenames[0], "/")
- filename := filenames[len(filenames)-2] + "/" + filenames[len(filenames)-1]
- if strings.Contains(filename, "../") {
- t.Fatal("relative path should have been sanitized out")
- }
- fileId := strings.Split(filename, ".")[0]
+ if utils.Cfg.FileSettings.DriverName == "" {
+ t.Skip("skipping because no file driver is enabled")
+ }
- var auth aws.Auth
- auth.AccessKey = utils.Cfg.FileSettings.AmazonS3AccessKeyId
- auth.SecretKey = utils.Cfg.FileSettings.AmazonS3SecretAccessKey
+ Client := th.BasicClient
+ channel := th.BasicChannel
- s := s3.New(auth, aws.Regions[utils.Cfg.FileSettings.AmazonS3Region])
- bucket := s.Bucket(utils.Cfg.FileSettings.AmazonS3Bucket)
+ var fileId string
+ data, err := readTestFile("test.png")
+ if err != nil {
+ t.Fatal(err)
+ } else {
+ fileId = Client.MustGeneric(Client.UploadPostAttachment(data, channel.Id, "test.png")).(*model.FileUploadResponse).FileInfos[0].Id
+ }
- // wait a bit for files to ready
- time.Sleep(5 * time.Second)
+ // Wait a bit for files to ready
+ time.Sleep(2 * time.Second)
- err = bucket.Del("teams/" + team.Id + "/channels/" + channel.Id + "/users/" + user.Id + "/" + filename)
+ if body, err := Client.GetFile(fileId); err != nil {
+ t.Fatal(err)
+ } else {
+ received, err := ioutil.ReadAll(body)
if err != nil {
t.Fatal(err)
+ } else if len(received) != len(data) {
+ t.Fatal("received file should be the same size as the sent one")
}
- err = bucket.Del("teams/" + team.Id + "/channels/" + channel.Id + "/users/" + user.Id + "/" + fileId + "_thumb.jpg")
- if err != nil {
- t.Fatal(err)
+ for i := range data {
+ if data[i] != received[i] {
+ t.Fatal("received file didn't match sent one")
+ }
}
- err = bucket.Del("teams/" + team.Id + "/channels/" + channel.Id + "/users/" + user.Id + "/" + fileId + "_preview.jpg")
+ body.Close()
+ }
+
+ // Other user shouldn't be able to get file for this file before it's attached to a post
+ th.LoginBasic2()
+
+ if _, err := Client.GetFile(fileId); err == nil {
+ t.Fatal("other user shouldn't be able to get file before it's attached to a post")
+ }
+
+ // Hacky way to assign file to a post (usually would be done by CreatePost call)
+ store.Must(Srv.Store.FileInfo().AttachToPost(fileId, th.BasicPost.Id))
+
+ // Other user shouldn't be able to get file for this file if they're not in the channel for it
+ if _, err := Client.GetFile(fileId); err == nil {
+ t.Fatal("other user shouldn't be able to get file when not in channel")
+ }
+
+ Client.Must(Client.JoinChannel(channel.Id))
+
+ // Other user should now be able to get file
+ if body, err := Client.GetFile(fileId); err != nil {
+ t.Fatal(err)
+ } else {
+ received, err := ioutil.ReadAll(body)
if err != nil {
t.Fatal(err)
+ } else if len(received) != len(data) {
+ t.Fatal("received file should be the same size as the sent one")
}
- } else if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_LOCAL {
- filenames := strings.Split(resp.Data.(*model.FileUploadResponse).Filenames[0], "/")
- filename := filenames[len(filenames)-2] + "/" + filenames[len(filenames)-1]
- if strings.Contains(filename, "../") {
- t.Fatal("relative path should have been sanitized out")
- }
- fileId := strings.Split(filename, ".")[0]
-
- // wait a bit for files to ready
- time.Sleep(5 * time.Second)
- path := utils.Cfg.FileSettings.Directory + "teams/" + team.Id + "/channels/" + channel.Id + "/users/" + user.Id + "/" + filename
- if err := os.Remove(path); err != nil {
- t.Fatal("Couldn't remove file at " + path)
+ for i := range data {
+ if data[i] != received[i] {
+ t.Fatal("received file didn't match sent one")
+ }
}
- path = utils.Cfg.FileSettings.Directory + "teams/" + team.Id + "/channels/" + channel.Id + "/users/" + user.Id + "/" + fileId + "_thumb.jpg"
- if err := os.Remove(path); err != nil {
- t.Fatal("Couldn't remove file at " + path)
- }
+ body.Close()
+ }
- path = utils.Cfg.FileSettings.Directory + "teams/" + team.Id + "/channels/" + channel.Id + "/users/" + user.Id + "/" + fileId + "_preview.jpg"
- if err := os.Remove(path); err != nil {
- t.Fatal("Couldn't remove file at " + path)
- }
- } else {
- if appErr == nil {
- t.Fatal("S3 and local storage not configured, should have failed")
- }
+ if err := cleanupTestFile(store.Must(Srv.Store.FileInfo().Get(fileId)).(*model.FileInfo)); err != nil {
+ t.Fatal(err)
}
}
-func TestGetFile(t *testing.T) {
+func TestGetFileThumbnail(t *testing.T) {
th := Setup().InitBasic()
+
+ if utils.Cfg.FileSettings.DriverName == "" {
+ t.Skip("skipping because no file driver is enabled")
+ }
+
Client := th.BasicClient
- team := th.BasicTeam
- user := th.BasicUser
channel := th.BasicChannel
- if utils.Cfg.FileSettings.DriverName != "" {
- body := &bytes.Buffer{}
- writer := multipart.NewWriter(body)
- part, err := writer.CreateFormFile("files", "test.png")
- if err != nil {
- t.Fatal(err)
- }
+ var fileId string
+ data, err := readTestFile("test.png")
+ if err != nil {
+ t.Fatal(err)
+ } else {
+ fileId = Client.MustGeneric(Client.UploadPostAttachment(data, channel.Id, "test.png")).(*model.FileUploadResponse).FileInfos[0].Id
+ }
- path := utils.FindDir("tests")
- file, err := os.Open(path + "/test.png")
- if err != nil {
- t.Fatal(err)
- }
- defer file.Close()
+ // Wait a bit for files to ready
+ time.Sleep(2 * time.Second)
- _, err = io.Copy(part, file)
- if err != nil {
- t.Fatal(err)
- }
+ if body, err := Client.GetFileThumbnail(fileId); err != nil {
+ t.Fatal(err)
+ } else {
+ body.Close()
+ }
- field, err := writer.CreateFormField("channel_id")
- if err != nil {
- t.Fatal(err)
- }
+ // Other user shouldn't be able to get thumbnail for this file before it's attached to a post
+ th.LoginBasic2()
- _, err = field.Write([]byte(channel.Id))
- if err != nil {
- t.Fatal(err)
- }
+ if _, err := Client.GetFileThumbnail(fileId); err == nil {
+ t.Fatal("other user shouldn't be able to get file before it's attached to a post")
+ }
- err = writer.Close()
- if err != nil {
- t.Fatal(err)
- }
+ // Hacky way to assign file to a post (usually would be done by CreatePost call)
+ store.Must(Srv.Store.FileInfo().AttachToPost(fileId, th.BasicPost.Id))
- resp, upErr := Client.UploadPostAttachment(body.Bytes(), writer.FormDataContentType())
- if upErr != nil {
- t.Fatal(upErr)
- }
+ // Other user shouldn't be able to get thumbnail for this file if they're not in the channel for it
+ if _, err := Client.GetFileThumbnail(fileId); err == nil {
+ t.Fatal("other user shouldn't be able to get file when not in channel")
+ }
- filenames := resp.Data.(*model.FileUploadResponse).Filenames
+ Client.Must(Client.JoinChannel(channel.Id))
- // wait a bit for files to ready
- time.Sleep(5 * time.Second)
+ // Other user should now be able to get thumbnail
+ if body, err := Client.GetFileThumbnail(fileId); err != nil {
+ t.Fatal(err)
+ } else {
+ body.Close()
+ }
- if _, downErr := Client.GetFile(filenames[0], false); downErr != nil {
- t.Fatal(downErr)
- }
+ if err := cleanupTestFile(store.Must(Srv.Store.FileInfo().Get(fileId)).(*model.FileInfo)); err != nil {
+ t.Fatal(err)
+ }
+}
- if resp, downErr := Client.GetFileInfo(filenames[0]); downErr != nil {
- t.Fatal(downErr)
- } else {
- info := resp.Data.(*model.FileInfo)
- if info.Size == 0 {
- t.Fatal("No file size returned")
- }
- }
+func TestGetFilePreview(t *testing.T) {
+ th := Setup().InitBasic()
+
+ if utils.Cfg.FileSettings.DriverName == "" {
+ t.Skip("skipping because no file driver is enabled")
+ }
+
+ Client := th.BasicClient
+ channel := th.BasicChannel
- if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_S3 {
- var auth aws.Auth
- auth.AccessKey = utils.Cfg.FileSettings.AmazonS3AccessKeyId
- auth.SecretKey = utils.Cfg.FileSettings.AmazonS3SecretAccessKey
+ var fileId string
+ data, err := readTestFile("test.png")
+ if err != nil {
+ t.Fatal(err)
+ } else {
+ fileId = Client.MustGeneric(Client.UploadPostAttachment(data, channel.Id, "test.png")).(*model.FileUploadResponse).FileInfos[0].Id
+ }
- s := s3.New(auth, aws.Regions[utils.Cfg.FileSettings.AmazonS3Region])
- bucket := s.Bucket(utils.Cfg.FileSettings.AmazonS3Bucket)
+ // Wait a bit for files to ready
+ time.Sleep(2 * time.Second)
- filenames := strings.Split(resp.Data.(*model.FileUploadResponse).Filenames[0], "/")
- filename := filenames[len(filenames)-2] + "/" + filenames[len(filenames)-1]
- fileId := strings.Split(filename, ".")[0]
+ if body, err := Client.GetFilePreview(fileId); err != nil {
+ t.Fatal(err)
+ } else {
+ body.Close()
+ }
- err = bucket.Del("teams/" + team.Id + "/channels/" + channel.Id + "/users/" + user.Id + "/" + filename)
- if err != nil {
- t.Fatal(err)
- }
+ // Other user shouldn't be able to get preview for this file before it's attached to a post
+ th.LoginBasic2()
- err = bucket.Del("teams/" + team.Id + "/channels/" + channel.Id + "/users/" + user.Id + "/" + fileId + "_thumb.jpg")
- if err != nil {
- t.Fatal(err)
- }
+ if _, err := Client.GetFilePreview(fileId); err == nil {
+ t.Fatal("other user shouldn't be able to get file before it's attached to a post")
+ }
- err = bucket.Del("teams/" + team.Id + "/channels/" + channel.Id + "/users/" + user.Id + "/" + fileId + "_preview.jpg")
- if err != nil {
- t.Fatal(err)
- }
- } else {
- filenames := strings.Split(resp.Data.(*model.FileUploadResponse).Filenames[0], "/")
- filename := filenames[len(filenames)-2] + "/" + filenames[len(filenames)-1]
- fileId := strings.Split(filename, ".")[0]
-
- path := utils.Cfg.FileSettings.Directory + "teams/" + team.Id + "/channels/" + channel.Id + "/users/" + user.Id + "/" + filename
- if err := os.Remove(path); err != nil {
- t.Fatal("Couldn't remove file at " + path)
- }
+ // Hacky way to assign file to a post (usually would be done by CreatePost call)
+ store.Must(Srv.Store.FileInfo().AttachToPost(fileId, th.BasicPost.Id))
- path = utils.Cfg.FileSettings.Directory + "teams/" + team.Id + "/channels/" + channel.Id + "/users/" + user.Id + "/" + fileId + "_thumb.jpg"
- if err := os.Remove(path); err != nil {
- t.Fatal("Couldn't remove file at " + path)
- }
+ // Other user shouldn't be able to get preview for this file if they're not in the channel for it
+ if _, err := Client.GetFilePreview(fileId); err == nil {
+ t.Fatal("other user shouldn't be able to get file when not in channel")
+ }
- path = utils.Cfg.FileSettings.Directory + "teams/" + team.Id + "/channels/" + channel.Id + "/users/" + user.Id + "/" + fileId + "_preview.jpg"
- if err := os.Remove(path); err != nil {
- t.Fatal("Couldn't remove file at " + path)
- }
- }
+ Client.Must(Client.JoinChannel(channel.Id))
+
+ // Other user should now be able to get preview
+ if body, err := Client.GetFilePreview(fileId); err != nil {
+ t.Fatal(err)
} else {
- if _, downErr := Client.GetFile("/files/get/yxebdmbz5pgupx7q6ez88rw11a/n3btzxu9hbnapqk36iwaxkjxhc/junk.jpg", false); downErr.StatusCode != http.StatusNotImplemented {
- t.Fatal("Status code should have been 501 - Not Implemented")
- }
+ body.Close()
+ }
+
+ if err := cleanupTestFile(store.Must(Srv.Store.FileInfo().Get(fileId)).(*model.FileInfo)); err != nil {
+ t.Fatal(err)
}
}
func TestGetPublicFile(t *testing.T) {
th := Setup().InitBasic()
- Client := th.BasicClient
- channel := th.BasicChannel
+
+ if utils.Cfg.FileSettings.DriverName == "" {
+ t.Skip("skipping because no file driver is enabled")
+ }
enablePublicLink := utils.Cfg.FileSettings.EnablePublicLink
- driverName := utils.Cfg.FileSettings.DriverName
+ publicLinkSalt := *utils.Cfg.FileSettings.PublicLinkSalt
defer func() {
utils.Cfg.FileSettings.EnablePublicLink = enablePublicLink
- utils.Cfg.FileSettings.DriverName = driverName
+ *utils.Cfg.FileSettings.PublicLinkSalt = publicLinkSalt
}()
utils.Cfg.FileSettings.EnablePublicLink = true
- if driverName == "" {
- driverName = model.IMAGE_DRIVER_LOCAL
- }
+ *utils.Cfg.FileSettings.PublicLinkSalt = model.NewId()
+
+ Client := th.BasicClient
+ channel := th.BasicChannel
- filenames, err := uploadTestFile(Client, channel.Id)
+ var fileId string
+ data, err := readTestFile("test.png")
if err != nil {
- t.Fatal("failed to upload test file", err)
+ t.Fatal(err)
+ } else {
+ fileId = Client.MustGeneric(Client.UploadPostAttachment(data, channel.Id, "test.png")).(*model.FileUploadResponse).FileInfos[0].Id
}
- post1 := &model.Post{ChannelId: channel.Id, Message: "a" + model.NewId() + "a", Filenames: filenames}
+ // Hacky way to assign file to a post (usually would be done by CreatePost call)
+ store.Must(Srv.Store.FileInfo().AttachToPost(fileId, th.BasicPost.Id))
- if rpost1, postErr := Client.CreatePost(post1); postErr != nil {
- t.Fatal(postErr)
- } else {
- post1 = rpost1.Data.(*model.Post)
- }
+ link := Client.MustGeneric(Client.GetPublicLink(fileId)).(string)
- var link string
- if result, err := Client.GetPublicLink(filenames[0]); err != nil {
- t.Fatal("failed to get public link")
- } else {
- link = result.Data.(string)
- }
+ // Wait a bit for files to ready
+ time.Sleep(2 * time.Second)
- // test a user that's logged in
- if resp, err := http.Get(link); err != nil && resp.StatusCode != http.StatusOK {
- t.Fatal("failed to get image with public link while logged in", err)
+ if resp, err := http.Get(link); err != nil || resp.StatusCode != http.StatusOK {
+ t.Fatal("failed to get image with public link", err)
}
if resp, err := http.Get(link[:strings.LastIndex(link, "?")]); err == nil && resp.StatusCode != http.StatusBadRequest {
- t.Fatal("should've failed to get image with public link while logged in without hash", resp.Status)
+ t.Fatal("should've failed to get image with public link without hash", resp.Status)
}
utils.Cfg.FileSettings.EnablePublicLink = false
if resp, err := http.Get(link); err == nil && resp.StatusCode != http.StatusNotImplemented {
- t.Fatal("should've failed to get image with disabled public link while logged in")
+ t.Fatal("should've failed to get image with disabled public link")
}
utils.Cfg.FileSettings.EnablePublicLink = true
- // test a user that's logged out
- Client.Must(Client.Logout())
+ // test after the salt has changed
+ *utils.Cfg.FileSettings.PublicLinkSalt = model.NewId()
+
+ if resp, err := http.Get(link); err == nil && resp.StatusCode != http.StatusBadRequest {
+ t.Fatal("should've failed to get image with public link after salt changed")
+ }
+
+ if resp, err := http.Get(link); err == nil && resp.StatusCode != http.StatusBadRequest {
+ t.Fatal("should've failed to get image with public link after salt changed")
+ }
- if resp, err := http.Get(link); err != nil && resp.StatusCode != http.StatusOK {
- t.Fatal("failed to get image with public link while not logged in", err)
+ if err := cleanupTestFile(store.Must(Srv.Store.FileInfo().Get(fileId)).(*model.FileInfo)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestGetPublicFileOld(t *testing.T) {
+ th := Setup().InitBasic()
+
+ if utils.Cfg.FileSettings.DriverName == "" {
+ t.Skip("skipping because no file driver is enabled")
+ }
+
+ enablePublicLink := utils.Cfg.FileSettings.EnablePublicLink
+ publicLinkSalt := *utils.Cfg.FileSettings.PublicLinkSalt
+ defer func() {
+ utils.Cfg.FileSettings.EnablePublicLink = enablePublicLink
+ *utils.Cfg.FileSettings.PublicLinkSalt = publicLinkSalt
+ }()
+ utils.Cfg.FileSettings.EnablePublicLink = true
+ *utils.Cfg.FileSettings.PublicLinkSalt = model.NewId()
+
+ Client := th.BasicClient
+ channel := th.BasicChannel
+
+ var fileId string
+ data, err := readTestFile("test.png")
+ if err != nil {
+ t.Fatal(err)
+ } else {
+ fileId = Client.MustGeneric(Client.UploadPostAttachment(data, channel.Id, "test.png")).(*model.FileUploadResponse).FileInfos[0].Id
+ }
+
+ // Hacky way to assign file to a post (usually would be done by CreatePost call)
+ store.Must(Srv.Store.FileInfo().AttachToPost(fileId, th.BasicPost.Id))
+
+ // reconstruct old style of link
+ siteURL := *utils.Cfg.ServiceSettings.SiteURL
+ if siteURL == "" {
+ siteURL = "http://localhost" + utils.Cfg.ServiceSettings.ListenAddress
+ }
+ link := generatePublicLinkOld(siteURL, th.BasicTeam.Id, channel.Id, th.BasicUser.Id, fileId+"/test.png")
+
+ // Wait a bit for files to ready
+ time.Sleep(2 * time.Second)
+
+ if resp, err := http.Get(link); err != nil || resp.StatusCode != http.StatusOK {
+ t.Fatalf("failed to get image with public link err=%v resp=%v", err, resp)
}
if resp, err := http.Get(link[:strings.LastIndex(link, "?")]); err == nil && resp.StatusCode != http.StatusBadRequest {
- t.Fatal("should've failed to get image with public link while not logged in without hash")
+ t.Fatal("should've failed to get image with public link without hash", resp.Status)
}
utils.Cfg.FileSettings.EnablePublicLink = false
if resp, err := http.Get(link); err == nil && resp.StatusCode != http.StatusNotImplemented {
- t.Fatal("should've failed to get image with disabled public link while not logged in")
+ t.Fatal("should've failed to get image with disabled public link")
}
utils.Cfg.FileSettings.EnablePublicLink = true
- // test a user that's logged in after the salt has changed
+ // test after the salt has changed
*utils.Cfg.FileSettings.PublicLinkSalt = model.NewId()
- th.LoginBasic()
if resp, err := http.Get(link); err == nil && resp.StatusCode != http.StatusBadRequest {
- t.Fatal("should've failed to get image with public link while logged in after salt changed")
+ t.Fatal("should've failed to get image with public link after salt changed")
}
- Client.Must(Client.Logout())
if resp, err := http.Get(link); err == nil && resp.StatusCode != http.StatusBadRequest {
- t.Fatal("should've failed to get image with public link while not logged in after salt changed")
+ t.Fatal("should've failed to get image with public link after salt changed")
}
- if err := cleanupTestFile(filenames[0], th.BasicTeam.Id, channel.Id, th.BasicUser.Id); err != nil {
- t.Fatal("failed to cleanup test file", err)
+ if err := cleanupTestFile(store.Must(Srv.Store.FileInfo().Get(fileId)).(*model.FileInfo)); err != nil {
+ t.Fatal(err)
}
}
+func generatePublicLinkOld(siteURL, teamId, channelId, userId, filename string) string {
+ hash := generatePublicLinkHash(filename, *utils.Cfg.FileSettings.PublicLinkSalt)
+ return fmt.Sprintf("%s%s/public/files/get/%s/%s/%s/%s?h=%s", siteURL, model.API_URL_SUFFIX, teamId, channelId, userId, filename, hash)
+}
+
func TestGetPublicLink(t *testing.T) {
th := Setup().InitBasic()
- Client := th.BasicClient
- channel := th.BasicChannel
+
+ if utils.Cfg.FileSettings.DriverName == "" {
+ t.Skip("skipping because no file driver is enabled")
+ }
enablePublicLink := utils.Cfg.FileSettings.EnablePublicLink
- driverName := utils.Cfg.FileSettings.DriverName
defer func() {
utils.Cfg.FileSettings.EnablePublicLink = enablePublicLink
- utils.Cfg.FileSettings.DriverName = driverName
}()
- if driverName == "" {
- driverName = model.IMAGE_DRIVER_LOCAL
- }
+ utils.Cfg.FileSettings.EnablePublicLink = true
- filenames, err := uploadTestFile(Client, channel.Id)
+ Client := th.BasicClient
+ channel := th.BasicChannel
+
+ var fileId string
+ data, err := readTestFile("test.png")
if err != nil {
- t.Fatal("failed to upload test file", err)
+ t.Fatal(err)
+ } else {
+ fileId = Client.MustGeneric(Client.UploadPostAttachment(data, channel.Id, "test.png")).(*model.FileUploadResponse).FileInfos[0].Id
}
- post1 := &model.Post{ChannelId: channel.Id, Message: "a" + model.NewId() + "a", Filenames: filenames}
-
- if rpost1, postErr := Client.CreatePost(post1); postErr != nil {
- t.Fatal(postErr)
- } else {
- post1 = rpost1.Data.(*model.Post)
+ if _, err := Client.GetPublicLink(fileId); err == nil {
+ t.Fatal("should've failed to get public link before file is attached to a post")
}
+ // Hacky way to assign file to a post (usually would be done by CreatePost call)
+ store.Must(Srv.Store.FileInfo().AttachToPost(fileId, th.BasicPost.Id))
+
utils.Cfg.FileSettings.EnablePublicLink = false
- if _, err := Client.GetPublicLink(filenames[0]); err == nil || err.StatusCode != http.StatusNotImplemented {
- t.Fatal("should've failed when public links are disabled", err)
+
+ if _, err := Client.GetPublicLink(fileId); err == nil {
+ t.Fatal("should've failed to get public link when disabled")
}
utils.Cfg.FileSettings.EnablePublicLink = true
- if _, err := Client.GetPublicLink("garbage"); err == nil {
- t.Fatal("should've failed for invalid link")
+ if link, err := Client.GetPublicLink(fileId); err != nil {
+ t.Fatal(err)
+ } else if link == "" {
+ t.Fatal("should've received public link")
}
- if _, err := Client.GetPublicLink(filenames[0]); err != nil {
- t.Fatal("should've gotten link for file", err)
+ // Other user shouldn't be able to get public link for this file if they're not in the channel for it
+ th.LoginBasic2()
+
+ if _, err := Client.GetPublicLink(fileId); err == nil {
+ t.Fatal("other user shouldn't be able to get file when not in channel")
}
- th.LoginBasic2()
+ Client.Must(Client.JoinChannel(channel.Id))
- if _, err := Client.GetPublicLink(filenames[0]); err == nil {
- t.Fatal("should've failed, user not member of channel")
+ // Other user should now be able to get public link
+ if link, err := Client.GetPublicLink(fileId); err != nil {
+ t.Fatal(err)
+ } else if link == "" {
+ t.Fatal("should've received public link")
}
- th.LoginBasic()
+ // Wait a bit for files to ready
+ time.Sleep(2 * time.Second)
- if err := cleanupTestFile(filenames[0], th.BasicTeam.Id, channel.Id, th.BasicUser.Id); err != nil {
- t.Fatal("failed to cleanup test file", err)
+ if err := cleanupTestFile(store.Must(Srv.Store.FileInfo().Get(fileId)).(*model.FileInfo)); err != nil {
+ t.Fatal(err)
}
}
@@ -415,48 +602,234 @@ func TestGeneratePublicLinkHash(t *testing.T) {
}
}
-func uploadTestFile(Client *model.Client, channelId string) ([]string, error) {
- body := &bytes.Buffer{}
- writer := multipart.NewWriter(body)
- part, err := writer.CreateFormFile("files", "test.png")
+func TestMigrateFilenamesToFileInfos(t *testing.T) {
+ th := Setup().InitBasic()
+
+ if utils.Cfg.FileSettings.DriverName == "" {
+ t.Skip("skipping because no file driver is enabled")
+ }
+
+ Client := th.BasicClient
+
+ user1 := th.BasicUser
+
+ channel1 := Client.Must(Client.CreateChannel(&model.Channel{
+ Name: model.NewId(),
+ Type: model.CHANNEL_OPEN,
+ // No TeamId set to simulate a direct channel
+ })).Data.(*model.Channel)
+
+ var fileId1 string
+ var fileId2 string
+ data, err := readTestFile("test.png")
if err != nil {
- return nil, err
+ t.Fatal(err)
+ } else {
+ fileId1 = Client.MustGeneric(Client.UploadPostAttachment(data, channel1.Id, "test.png")).(*model.FileUploadResponse).FileInfos[0].Id
+ fileId2 = Client.MustGeneric(Client.UploadPostAttachment(data, channel1.Id, "test.png")).(*model.FileUploadResponse).FileInfos[0].Id
+ }
+
+ // Bypass the Client whenever possible since we're trying to simulate a pre-3.5 post
+ post1 := store.Must(Srv.Store.Post().Save(&model.Post{
+ UserId: user1.Id,
+ ChannelId: channel1.Id,
+ Message: "test",
+ Filenames: []string{
+ fmt.Sprintf("/%s/%s/%s/%s", channel1.Id, user1.Id, fileId1, "test.png"),
+ fmt.Sprintf("/%s/%s/%s/%s", channel1.Id, user1.Id, fileId2, "test.png"),
+ fmt.Sprintf("/%s/%s/%s/%s", channel1.Id, user1.Id, fileId2, "test.png"), // duplicate a filename to recreate a rare bug
+ },
+ })).(*model.Post)
+
+ if post1.FileIds != nil && len(post1.FileIds) > 0 {
+ t.Fatal("post shouldn't have file ids")
+ } else if post1.Filenames == nil || len(post1.Filenames) != 3 {
+ t.Fatal("post should have filenames")
+ }
+
+ // Indirectly call migrateFilenamesToFileInfos by calling Client.GetFileInfosForPost
+ var infos []*model.FileInfo
+ if infosResult, err := Client.GetFileInfosForPost(post1.ChannelId, post1.Id, ""); err != nil {
+ t.Fatal(err)
+ } else {
+ infos = infosResult
}
- // base 64 encoded version of handtinywhite.gif from http://probablyprogramming.com/2009/03/15/the-tiniest-gif-ever
- file, _ := base64.StdEncoding.DecodeString("R0lGODlhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=")
+ if len(infos) != 2 {
+ t.Log(infos)
+ t.Fatal("should've had 2 infos after migration")
+ } else if infos[0].Path != "" || infos[0].ThumbnailPath != "" || infos[0].PreviewPath != "" {
+ t.Fatal("shouldn't return paths to client")
+ }
- if _, err := io.Copy(part, bytes.NewReader(file)); err != nil {
- return nil, err
+ // Should be able to get files after migration
+ if body, err := Client.GetFile(infos[0].Id); err != nil {
+ t.Fatal(err)
+ } else {
+ body.Close()
}
- field, err := writer.CreateFormField("channel_id")
+ if body, err := Client.GetFile(infos[1].Id); err != nil {
+ t.Fatal(err)
+ } else {
+ body.Close()
+ }
+
+ // Make sure we aren't generating a new set of FileInfos on a second call to GetFileInfosForPost
+ if infos2 := Client.MustGeneric(Client.GetFileInfosForPost(post1.ChannelId, post1.Id, "")).([]*model.FileInfo); len(infos2) != len(infos) {
+ t.Fatal("should've received the same 2 infos after second call")
+ } else if (infos[0].Id != infos2[0].Id && infos[0].Id != infos2[1].Id) || (infos[1].Id != infos2[0].Id && infos[1].Id != infos2[1].Id) {
+ t.Fatal("should've returned the exact same 2 infos after second call")
+ }
+
+ if result, err := Client.GetPost(post1.ChannelId, post1.Id, ""); err != nil {
+ t.Fatal(err)
+ } else if post := result.Data.(*model.PostList).Posts[post1.Id]; len(post.Filenames) != 0 {
+ t.Fatal("post shouldn't have filenames")
+ } else if len(post.FileIds) != 2 {
+ t.Fatal("post should have 2 file ids")
+ } else if (infos[0].Id != post.FileIds[0] && infos[0].Id != post.FileIds[1]) || (infos[1].Id != post.FileIds[0] && infos[1].Id != post.FileIds[1]) {
+ t.Fatal("post file ids should match GetFileInfosForPost results")
+ }
+}
+
+func TestFindTeamIdForFilename(t *testing.T) {
+ th := Setup().InitBasic()
+
+ if utils.Cfg.FileSettings.DriverName == "" {
+ t.Skip("skipping because no file driver is enabled")
+ }
+
+ Client := th.BasicClient
+
+ user1 := th.BasicUser
+
+ team1 := th.BasicTeam
+ team2 := th.CreateTeam(th.BasicClient)
+
+ channel1 := th.BasicChannel
+
+ Client.SetTeamId(team2.Id)
+ channel2 := Client.Must(Client.CreateChannel(&model.Channel{
+ Name: model.NewId(),
+ Type: model.CHANNEL_OPEN,
+ // No TeamId set to simulate a direct channel
+ })).Data.(*model.Channel)
+ Client.SetTeamId(team1.Id)
+
+ var fileId1 string
+ var fileId2 string
+ data, err := readTestFile("test.png")
if err != nil {
- return nil, err
+ t.Fatal(err)
+ } else {
+ fileId1 = Client.MustGeneric(Client.UploadPostAttachment(data, channel1.Id, "test.png")).(*model.FileUploadResponse).FileInfos[0].Id
+
+ Client.SetTeamId(team2.Id)
+ fileId2 = Client.MustGeneric(Client.UploadPostAttachment(data, channel2.Id, "test.png")).(*model.FileUploadResponse).FileInfos[0].Id
+ Client.SetTeamId(team1.Id)
}
- if _, err := field.Write([]byte(channelId)); err != nil {
- return nil, err
+ // Bypass the Client whenever possible since we're trying to simulate a pre-3.5 post
+ post1 := store.Must(Srv.Store.Post().Save(&model.Post{
+ UserId: user1.Id,
+ ChannelId: channel1.Id,
+ Message: "test",
+ Filenames: []string{fmt.Sprintf("/%s/%s/%s/%s", channel1.Id, user1.Id, fileId1, "test.png")},
+ })).(*model.Post)
+
+ if teamId := findTeamIdForFilename(post1, post1.Filenames[0]); teamId != team1.Id {
+ t.Fatal("file should've been found under team1")
+ }
+
+ Client.SetTeamId(team2.Id)
+ post2 := store.Must(Srv.Store.Post().Save(&model.Post{
+ UserId: user1.Id,
+ ChannelId: channel2.Id,
+ Message: "test",
+ Filenames: []string{fmt.Sprintf("/%s/%s/%s/%s", channel2.Id, user1.Id, fileId2, "test.png")},
+ })).(*model.Post)
+ Client.SetTeamId(team1.Id)
+
+ if teamId := findTeamIdForFilename(post2, post2.Filenames[0]); teamId != team2.Id {
+ t.Fatal("file should've been found under team2")
+ }
+}
+
+func TestGetInfoForFilename(t *testing.T) {
+ th := Setup().InitBasic()
+
+ if utils.Cfg.FileSettings.DriverName == "" {
+ t.Skip("skipping because no file driver is enabled")
+ }
+
+ Client := th.BasicClient
+
+ user1 := th.BasicUser
+
+ team1 := th.BasicTeam
+
+ channel1 := th.BasicChannel
+
+ var fileId1 string
+ var path string
+ var thumbnailPath string
+ var previewPath string
+ data, err := readTestFile("test.png")
+ if err != nil {
+ t.Fatal(err)
+ } else {
+ fileId1 = Client.MustGeneric(Client.UploadPostAttachment(data, channel1.Id, "test.png")).(*model.FileUploadResponse).FileInfos[0].Id
+ path = store.Must(Srv.Store.FileInfo().Get(fileId1)).(*model.FileInfo).Path
+ thumbnailPath = store.Must(Srv.Store.FileInfo().Get(fileId1)).(*model.FileInfo).ThumbnailPath
+ previewPath = store.Must(Srv.Store.FileInfo().Get(fileId1)).(*model.FileInfo).PreviewPath
+ }
+
+ // Bypass the Client whenever possible since we're trying to simulate a pre-3.5 post
+ post1 := store.Must(Srv.Store.Post().Save(&model.Post{
+ UserId: user1.Id,
+ ChannelId: channel1.Id,
+ Message: "test",
+ Filenames: []string{fmt.Sprintf("/%s/%s/%s/%s", channel1.Id, user1.Id, fileId1, "test.png")},
+ })).(*model.Post)
+
+ if info := getInfoForFilename(post1, team1.Id, post1.Filenames[0]); info == nil {
+ t.Fatal("info shouldn't be nil")
+ } else if info.Id == "" {
+ t.Fatal("info.Id shouldn't be empty")
+ } else if info.CreatorId != user1.Id {
+ t.Fatal("incorrect user id")
+ } else if info.PostId != post1.Id {
+ t.Fatal("incorrect user id")
+ } else if info.Path != path {
+ t.Fatal("incorrect path")
+ } else if info.ThumbnailPath != thumbnailPath {
+ t.Fatal("incorrect thumbnail path")
+ } else if info.PreviewPath != previewPath {
+ t.Fatal("incorrect preview path")
+ } else if info.Name != "test.png" {
+ t.Fatal("incorrect name")
}
+}
- if err := writer.Close(); err != nil {
+func readTestFile(name string) ([]byte, error) {
+ path := utils.FindDir("tests")
+ file, err := os.Open(path + "/" + name)
+ if err != nil {
return nil, err
}
+ defer file.Close()
- if resp, err := Client.UploadPostAttachment(body.Bytes(), writer.FormDataContentType()); err != nil {
+ data := &bytes.Buffer{}
+ if _, err := io.Copy(data, file); err != nil {
return nil, err
} else {
- return resp.Data.(*model.FileUploadResponse).Filenames, nil
+ return data.Bytes(), nil
}
}
-func cleanupTestFile(fullFilename, teamId, channelId, userId string) error {
- filenames := strings.Split(fullFilename, "/")
- filename := filenames[len(filenames)-2] + "/" + filenames[len(filenames)-1]
- fileId := strings.Split(filename, ".")[0]
-
+func cleanupTestFile(info *model.FileInfo) error {
if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_S3 {
- // perform clean-up on s3
var auth aws.Auth
auth.AccessKey = utils.Cfg.FileSettings.AmazonS3AccessKeyId
auth.SecretKey = utils.Cfg.FileSettings.AmazonS3SecretAccessKey
@@ -464,31 +837,36 @@ func cleanupTestFile(fullFilename, teamId, channelId, userId string) error {
s := s3.New(auth, aws.Regions[utils.Cfg.FileSettings.AmazonS3Region])
bucket := s.Bucket(utils.Cfg.FileSettings.AmazonS3Bucket)
- if err := bucket.Del("teams/" + teamId + "/channels/" + channelId + "/users/" + userId + "/" + filename); err != nil {
+ if err := bucket.Del(info.Path); err != nil {
return err
}
- if err := bucket.Del("teams/" + teamId + "/channels/" + channelId + "/users/" + userId + "/" + fileId + "_thumb.jpg"); err != nil {
- return err
+ if info.ThumbnailPath != "" {
+ if err := bucket.Del(info.ThumbnailPath); err != nil {
+ return err
+ }
}
- if err := bucket.Del("teams/" + teamId + "/channels/" + channelId + "/users/" + userId + "/" + fileId + "_preview.jpg"); err != nil {
- return err
+ if info.PreviewPath != "" {
+ if err := bucket.Del(info.PreviewPath); err != nil {
+ return err
+ }
}
- } else {
- path := utils.Cfg.FileSettings.Directory + "teams/" + teamId + "/channels/" + channelId + "/users/" + userId + "/" + filename
- if err := os.Remove(path); err != nil {
- return fmt.Errorf("Couldn't remove file at " + path)
+ } else if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_LOCAL {
+ if err := os.Remove(utils.Cfg.FileSettings.Directory + info.Path); err != nil {
+ return err
}
- path = utils.Cfg.FileSettings.Directory + "teams/" + teamId + "/channels/" + channelId + "/users/" + userId + "/" + fileId + "_thumb.jpg"
- if err := os.Remove(path); err != nil {
- return fmt.Errorf("Couldn't remove file at " + path)
+ if info.ThumbnailPath != "" {
+ if err := os.Remove(utils.Cfg.FileSettings.Directory + info.ThumbnailPath); err != nil {
+ return err
+ }
}
- path = utils.Cfg.FileSettings.Directory + "teams/" + teamId + "/channels/" + channelId + "/users/" + userId + "/" + fileId + "_preview.jpg"
- if err := os.Remove(path); err != nil {
- return fmt.Errorf("Couldn't remove file at " + path)
+ if info.PreviewPath != "" {
+ if err := os.Remove(utils.Cfg.FileSettings.Directory + info.PreviewPath); err != nil {
+ return err
+ }
}
}
diff --git a/api/post.go b/api/post.go
index 1286a23d9..498f5b363 100644
--- a/api/post.go
+++ b/api/post.go
@@ -49,6 +49,7 @@ func InitPost() {
BaseRoutes.NeedPost.Handle("/delete", ApiUserRequired(deletePost)).Methods("POST")
BaseRoutes.NeedPost.Handle("/before/{offset:[0-9]+}/{num_posts:[0-9]+}", ApiUserRequired(getPostsBefore)).Methods("GET")
BaseRoutes.NeedPost.Handle("/after/{offset:[0-9]+}/{num_posts:[0-9]+}", ApiUserRequired(getPostsAfter)).Methods("GET")
+ BaseRoutes.NeedPost.Handle("/get_file_infos", ApiUserRequired(getFileInfosForPost)).Methods("GET")
}
func createPost(c *Context, w http.ResponseWriter, r *http.Request) {
@@ -135,48 +136,26 @@ func CreatePost(c *Context, post *model.Post, triggerWebhooks bool) (*model.Post
post.Hashtags, _ = model.ParseHashtags(post.Message)
- if len(post.Filenames) > 0 {
- doRemove := false
- for i := len(post.Filenames) - 1; i >= 0; i-- {
- path := post.Filenames[i]
-
- doRemove = false
- if model.UrlRegex.MatchString(path) {
- continue
- } else if model.PartialUrlRegex.MatchString(path) {
- matches := model.PartialUrlRegex.FindAllStringSubmatch(path, -1)
- if len(matches) == 0 || len(matches[0]) < 4 {
- doRemove = true
- }
-
- channelId := matches[0][1]
- if channelId != post.ChannelId {
- doRemove = true
- }
-
- userId := matches[0][2]
- if userId != post.UserId {
- doRemove = true
- }
- } else {
- doRemove = true
- }
- if doRemove {
- l4g.Error(utils.T("api.post.create_post.bad_filename.error"), path)
- post.Filenames = append(post.Filenames[:i], post.Filenames[i+1:]...)
- }
- }
- }
-
var rpost *model.Post
if result := <-Srv.Store.Post().Save(post); result.Err != nil {
return nil, result.Err
} else {
rpost = result.Data.(*model.Post)
+ }
+
+ if len(post.FileIds) > 0 {
+ // There's a rare bug where the client sends up duplicate FileIds so protect against that
+ post.FileIds = utils.RemoveDuplicatesFromStringArray(post.FileIds)
- go handlePostEvents(c, rpost, triggerWebhooks)
+ for _, fileId := range post.FileIds {
+ if result := <-Srv.Store.FileInfo().AttachToPost(fileId, post.Id); result.Err != nil {
+ l4g.Error(utils.T("api.post.create_post.attach_files.error"), post.Id, post.FileIds, c.Session.UserId, result.Err)
+ }
+ }
}
+ go handlePostEvents(c, rpost, triggerWebhooks)
+
return rpost, nil
}
@@ -566,6 +545,7 @@ func sendNotifications(c *Context, post *model.Post, team *model.Team, channel *
pchan := Srv.Store.User().GetProfiles(c.TeamId)
dpchan := Srv.Store.User().GetDirectProfiles(c.Session.UserId)
mchan := Srv.Store.Channel().GetMembers(post.ChannelId)
+ fchan := Srv.Store.FileInfo().GetForPost(post.Id)
var profileMap map[string]*model.User
if result := <-pchan; result.Err != nil {
@@ -785,12 +765,18 @@ func sendNotifications(c *Context, post *model.Post, team *model.Team, channel *
message.Add("sender_name", senderName)
message.Add("team_id", team.Id)
- if len(post.Filenames) != 0 {
+ if len(post.FileIds) != 0 {
message.Add("otherFile", "true")
- for _, filename := range post.Filenames {
- ext := filepath.Ext(filename)
- if model.IsFileExtImage(ext) {
+ var infos []*model.FileInfo
+ if result := <-fchan; result.Err != nil {
+ l4g.Warn(utils.T("api.post.send_notifications.files.error"), post.Id, result.Err)
+ } else {
+ infos = result.Data.([]*model.FileInfo)
+ }
+
+ for _, info := range infos {
+ if info.IsImage() {
message.Add("image", "true")
break
}
@@ -915,22 +901,29 @@ func sendNotificationEmail(c *Context, post *model.Post, user *model.User, chann
}
func getMessageForNotification(post *model.Post, translateFunc i18n.TranslateFunc) string {
- if len(strings.TrimSpace(post.Message)) != 0 || len(post.Filenames) == 0 {
+ if len(strings.TrimSpace(post.Message)) != 0 || len(post.FileIds) == 0 {
return post.Message
}
// extract the filenames from their paths and determine what type of files are attached
- filenames := make([]string, len(post.Filenames))
+ var infos []*model.FileInfo
+ if result := <-Srv.Store.FileInfo().GetForPost(post.Id); result.Err != nil {
+ l4g.Warn(utils.T("api.post.get_message_for_notification.get_files.error"), post.Id, result.Err)
+ } else {
+ infos = result.Data.([]*model.FileInfo)
+ }
+
+ filenames := make([]string, len(infos))
onlyImages := true
- for i, filename := range post.Filenames {
- var err error
- if filenames[i], err = url.QueryUnescape(filepath.Base(filename)); err != nil {
+ for i, info := range infos {
+ if escaped, err := url.QueryUnescape(filepath.Base(info.Name)); err != nil {
// this should never error since filepath was escaped using url.QueryEscape
- filenames[i] = filepath.Base(filename)
+ filenames[i] = escaped
+ } else {
+ filenames[i] = info.Name
}
- ext := filepath.Ext(filename)
- onlyImages = onlyImages && model.IsFileExtImage(ext)
+ onlyImages = onlyImages && info.IsImage()
}
props := map[string]interface{}{"Filenames": strings.Join(filenames, ", ")}
@@ -1099,9 +1092,6 @@ func SendEphemeralPost(teamId, userId string, post *model.Post) {
if post.Props == nil {
post.Props = model.StringInterface{}
}
- if post.Filenames == nil {
- post.Filenames = []string{}
- }
message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_EPHEMERAL_MESSAGE, "", post.ChannelId, userId, nil)
message.Add("post", post.ToJson())
@@ -1156,9 +1146,13 @@ func updatePost(c *Context, w http.ResponseWriter, r *http.Request) {
}
}
- hashtags, _ := model.ParseHashtags(post.Message)
+ newPost := &model.Post{}
+ *newPost = *oldPost
+
+ newPost.Message = post.Message
+ newPost.Hashtags, _ = model.ParseHashtags(post.Message)
- if result := <-Srv.Store.Post().Update(oldPost, post.Message, hashtags); result.Err != nil {
+ if result := <-Srv.Store.Post().Update(newPost, oldPost); result.Err != nil {
c.Err = result.Err
return
} else {
@@ -1449,7 +1443,7 @@ func deletePost(c *Context, w http.ResponseWriter, r *http.Request) {
message.Add("post", post.ToJson())
go Publish(message)
- go DeletePostFiles(c.TeamId, post)
+ go DeletePostFiles(post)
go DeleteFlaggedPost(c.Session.UserId, post)
result := make(map[string]string)
@@ -1465,17 +1459,13 @@ func DeleteFlaggedPost(userId string, post *model.Post) {
}
}
-func DeletePostFiles(teamId string, post *model.Post) {
- if len(post.Filenames) == 0 {
+func DeletePostFiles(post *model.Post) {
+ if len(post.FileIds) != 0 {
return
}
- prefix := "teams/" + teamId + "/channels/" + post.ChannelId + "/users/" + post.UserId + "/"
- for _, filename := range post.Filenames {
- splitUrl := strings.Split(filename, "/")
- oldPath := prefix + splitUrl[len(splitUrl)-2] + "/" + splitUrl[len(splitUrl)-1]
- newPath := prefix + splitUrl[len(splitUrl)-2] + "/deleted_" + splitUrl[len(splitUrl)-1]
- MoveFile(oldPath, newPath)
+ if result := <-Srv.Store.FileInfo().DeleteForPost(post.Id); result.Err != nil {
+ l4g.Warn(utils.T("api.post.delete_post_files.app_error.warn"), post.Id, result.Err)
}
}
@@ -1583,3 +1573,59 @@ func searchPosts(c *Context, w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Write([]byte(posts.ToJson()))
}
+
+func getFileInfosForPost(c *Context, w http.ResponseWriter, r *http.Request) {
+ params := mux.Vars(r)
+
+ channelId := params["channel_id"]
+ if len(channelId) != 26 {
+ c.SetInvalidParam("getFileInfosForPost", "channelId")
+ return
+ }
+
+ postId := params["post_id"]
+ if len(postId) != 26 {
+ c.SetInvalidParam("getFileInfosForPost", "postId")
+ return
+ }
+
+ pchan := Srv.Store.Post().Get(postId)
+ fchan := Srv.Store.FileInfo().GetForPost(postId)
+
+ if !HasPermissionToChannelContext(c, channelId, model.PERMISSION_READ_CHANNEL) {
+ return
+ }
+
+ var infos []*model.FileInfo
+ if result := <-fchan; result.Err != nil {
+ c.Err = result.Err
+ return
+ } else {
+ infos = result.Data.([]*model.FileInfo)
+ }
+
+ if len(infos) == 0 {
+ // No FileInfos were returned so check if they need to be created for this post
+ var post *model.Post
+ if result := <-pchan; result.Err != nil {
+ c.Err = result.Err
+ return
+ } else {
+ post = result.Data.(*model.PostList).Posts[postId]
+ }
+
+ if len(post.Filenames) > 0 {
+ // The post has Filenames that need to be replaced with FileInfos
+ infos = migrateFilenamesToFileInfos(post)
+ }
+ }
+
+ etag := model.GetEtagForFileInfos(infos)
+
+ if HandleEtag(etag, w, r) {
+ return
+ } else {
+ w.Header().Set(model.HEADER_ETAG_SERVER, etag)
+ w.Write([]byte(model.FileInfosToJson(infos)))
+ }
+}
diff --git a/api/post_benchmark_test.go b/api/post_benchmark_test.go
deleted file mode 100644
index 5424bc1dd..000000000
--- a/api/post_benchmark_test.go
+++ /dev/null
@@ -1,148 +0,0 @@
-// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
-// See License.txt for license information.
-
-package api
-
-import (
- "github.com/mattermost/platform/utils"
- "testing"
-)
-
-const (
- NUM_POSTS = 100
-)
-
-func BenchmarkCreatePost(b *testing.B) {
- var (
- NUM_POSTS_RANGE = utils.Range{NUM_POSTS, NUM_POSTS}
- )
-
- th := Setup().InitBasic()
- Client := th.BasicClient
- channel := th.BasicChannel
-
- testPoster := NewAutoPostCreator(Client, channel.Id)
-
- // Benchmark Start
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- testPoster.CreateTestPosts(NUM_POSTS_RANGE)
- }
-}
-
-func BenchmarkUpdatePost(b *testing.B) {
- var (
- NUM_POSTS_RANGE = utils.Range{NUM_POSTS, NUM_POSTS}
- UPDATE_POST_LEN = 100
- )
-
- th := Setup().InitBasic()
- Client := th.BasicClient
- channel := th.BasicChannel
-
- testPoster := NewAutoPostCreator(Client, channel.Id)
- posts, valid := testPoster.CreateTestPosts(NUM_POSTS_RANGE)
- if valid == false {
- b.Fatal("Unable to create test posts")
- }
-
- for i := range posts {
- posts[i].Message = utils.RandString(UPDATE_POST_LEN, utils.ALPHANUMERIC)
- }
-
- // Benchmark Start
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- for i := range posts {
- if _, err := Client.UpdatePost(posts[i]); err != nil {
- b.Fatal(err)
- }
- }
- }
-}
-
-func BenchmarkGetPosts(b *testing.B) {
- var (
- NUM_POSTS_RANGE = utils.Range{NUM_POSTS, NUM_POSTS}
- )
-
- th := Setup().InitBasic()
- Client := th.BasicClient
- channel := th.BasicChannel
-
- testPoster := NewAutoPostCreator(Client, channel.Id)
- testPoster.CreateTestPosts(NUM_POSTS_RANGE)
-
- // Benchmark Start
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- Client.Must(Client.GetPosts(channel.Id, 0, NUM_POSTS, ""))
- }
-}
-
-func BenchmarkSearchPosts(b *testing.B) {
- var (
- NUM_POSTS_RANGE = utils.Range{NUM_POSTS, NUM_POSTS}
- )
-
- th := Setup().InitBasic()
- Client := th.BasicClient
- channel := th.BasicChannel
-
- testPoster := NewAutoPostCreator(Client, channel.Id)
- testPoster.CreateTestPosts(NUM_POSTS_RANGE)
-
- // Benchmark Start
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- Client.Must(Client.SearchPosts("nothere", false))
- Client.Must(Client.SearchPosts("n", false))
- Client.Must(Client.SearchPosts("#tag", false))
- }
-}
-
-func BenchmarkEtagCache(b *testing.B) {
- var (
- NUM_POSTS_RANGE = utils.Range{NUM_POSTS, NUM_POSTS}
- )
-
- th := Setup().InitBasic()
- Client := th.BasicClient
- channel := th.BasicChannel
-
- testPoster := NewAutoPostCreator(Client, channel.Id)
- testPoster.CreateTestPosts(NUM_POSTS_RANGE)
-
- etag := Client.Must(Client.GetPosts(channel.Id, 0, NUM_POSTS/2, "")).Etag
-
- // Benchmark Start
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- Client.Must(Client.GetPosts(channel.Id, 0, NUM_POSTS/2, etag))
- }
-}
-
-func BenchmarkDeletePosts(b *testing.B) {
- var (
- NUM_POSTS_RANGE = utils.Range{NUM_POSTS, NUM_POSTS}
- )
-
- th := Setup().InitBasic()
- Client := th.BasicClient
- channel := th.BasicChannel
-
- testPoster := NewAutoPostCreator(Client, channel.Id)
- posts, valid := testPoster.CreateTestPosts(NUM_POSTS_RANGE)
- if valid == false {
- b.Fatal("Unable to create test posts")
- }
-
- // Benchmark Start
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- for i := range posts {
- Client.Must(Client.DeletePost(channel.Id, posts[i].Id))
- }
- }
-
-}
diff --git a/api/post_test.go b/api/post_test.go
index 7b7832148..bdc5278e4 100644
--- a/api/post_test.go
+++ b/api/post_test.go
@@ -15,6 +15,7 @@ import (
"time"
"github.com/mattermost/platform/model"
+ "github.com/mattermost/platform/store"
"github.com/mattermost/platform/utils"
)
@@ -23,15 +24,12 @@ func TestCreatePost(t *testing.T) {
Client := th.BasicClient
team := th.BasicTeam
team2 := th.CreateTeam(th.BasicClient)
- user1 := th.BasicUser
user3 := th.CreateUser(th.BasicClient)
LinkUserToTeam(user3, team2)
channel1 := th.BasicChannel
channel2 := th.CreateChannel(Client, team)
- filenames := []string{"/12345678901234567890123456/12345678901234567890123456/12345678901234567890123456/test.png", "/" + channel1.Id + "/" + user1.Id + "/test.png", "www.mattermost.com/fake/url", "junk"}
-
- post1 := &model.Post{ChannelId: channel1.Id, Message: "#hashtag a" + model.NewId() + "a", Filenames: filenames}
+ post1 := &model.Post{ChannelId: channel1.Id, Message: "#hashtag a" + model.NewId() + "a"}
rpost1, err := Client.CreatePost(post1)
if err != nil {
t.Fatal(err)
@@ -45,8 +43,8 @@ func TestCreatePost(t *testing.T) {
t.Fatal("hashtag didn't match")
}
- if len(rpost1.Data.(*model.Post).Filenames) != 2 {
- t.Fatal("filenames didn't parse correctly")
+ if len(rpost1.Data.(*model.Post).FileIds) != 0 {
+ t.Fatal("shouldn't have files")
}
post2 := &model.Post{ChannelId: channel1.Id, Message: "a" + model.NewId() + "a", RootId: rpost1.Data.(*model.Post).Id}
@@ -109,6 +107,35 @@ func TestCreatePost(t *testing.T) {
if _, err = Client.DoApiPost("/channels/"+channel3.Id+"/create", "garbage"); err == nil {
t.Fatal("should have been an error")
}
+
+ fileIds := make([]string, 4)
+ if data, err := readTestFile("test.png"); err != nil {
+ t.Fatal(err)
+ } else {
+ for i := 0; i < 3; i++ {
+ fileIds[i] = Client.MustGeneric(Client.UploadPostAttachment(data, channel3.Id, "test.png")).(*model.FileUploadResponse).FileInfos[0].Id
+ }
+ }
+
+ // Make sure duplicated file ids are removed
+ fileIds[3] = fileIds[0]
+
+ post9 := &model.Post{
+ ChannelId: channel3.Id,
+ Message: "test",
+ FileIds: fileIds,
+ }
+ if resp, err := Client.CreatePost(post9); err != nil {
+ t.Fatal(err)
+ } else if rpost9 := resp.Data.(*model.Post); len(rpost9.FileIds) != 3 {
+ t.Fatal("post should have 3 files")
+ } else {
+ infos := store.Must(Srv.Store.FileInfo().GetForPost(rpost9.Id)).([]*model.FileInfo)
+
+ if len(infos) != 3 {
+ t.Fatal("should've attached all 3 files to post")
+ }
+ }
}
func testCreatePostWithOutgoingHook(
@@ -800,10 +827,8 @@ func TestFuzzyPosts(t *testing.T) {
Client := th.BasicClient
channel1 := th.BasicChannel
- filenames := []string{"junk"}
-
for i := 0; i < len(utils.FUZZY_STRINGS_POSTS); i++ {
- post := &model.Post{ChannelId: channel1.Id, Message: utils.FUZZY_STRINGS_POSTS[i], Filenames: filenames}
+ post := &model.Post{ChannelId: channel1.Id, Message: utils.FUZZY_STRINGS_POSTS[i]}
_, err := Client.CreatePost(post)
if err != nil {
@@ -1150,19 +1175,49 @@ func TestGetFlaggedPosts(t *testing.T) {
}
func TestGetMessageForNotification(t *testing.T) {
- Setup()
+ Setup().InitBasic()
+
+ testPng := store.Must(Srv.Store.FileInfo().Save(&model.FileInfo{
+ CreatorId: model.NewId(),
+ Path: "test1.png",
+ Name: "test1.png",
+ MimeType: "image/png",
+ })).(*model.FileInfo)
+
+ testJpg1 := store.Must(Srv.Store.FileInfo().Save(&model.FileInfo{
+ CreatorId: model.NewId(),
+ Path: "test2.jpg",
+ Name: "test2.jpg",
+ MimeType: "image/jpeg",
+ })).(*model.FileInfo)
+
+ testFile := store.Must(Srv.Store.FileInfo().Save(&model.FileInfo{
+ CreatorId: model.NewId(),
+ Path: "test1.go",
+ Name: "test1.go",
+ MimeType: "text/plain",
+ })).(*model.FileInfo)
+
+ testJpg2 := store.Must(Srv.Store.FileInfo().Save(&model.FileInfo{
+ CreatorId: model.NewId(),
+ Path: "test3.jpg",
+ Name: "test3.jpg",
+ MimeType: "image/jpeg",
+ })).(*model.FileInfo)
+
translateFunc := utils.GetUserTranslations("en")
post := &model.Post{
- Message: "test",
- Filenames: model.StringArray{},
+ Id: model.NewId(),
+ Message: "test",
}
if getMessageForNotification(post, translateFunc) != "test" {
t.Fatal("should've returned message text")
}
- post.Filenames = model.StringArray{"test1.png"}
+ post.FileIds = model.StringArray{testPng.Id}
+ store.Must(Srv.Store.FileInfo().AttachToPost(testPng.Id, post.Id))
if getMessageForNotification(post, translateFunc) != "test" {
t.Fatal("should've returned message text, even with attachments")
}
@@ -1172,18 +1227,60 @@ func TestGetMessageForNotification(t *testing.T) {
t.Fatal("should've returned number of images:", message)
}
- post.Filenames = model.StringArray{"test1.png", "test2.jpg"}
+ post.FileIds = model.StringArray{testPng.Id, testJpg1.Id}
+ store.Must(Srv.Store.FileInfo().AttachToPost(testJpg1.Id, post.Id))
if message := getMessageForNotification(post, translateFunc); message != "2 images sent: test1.png, test2.jpg" {
t.Fatal("should've returned number of images:", message)
}
- post.Filenames = model.StringArray{"test1.go"}
+ post.Id = model.NewId()
+ post.FileIds = model.StringArray{testFile.Id}
+ store.Must(Srv.Store.FileInfo().AttachToPost(testFile.Id, post.Id))
if message := getMessageForNotification(post, translateFunc); message != "1 file sent: test1.go" {
t.Fatal("should've returned number of files:", message)
}
- post.Filenames = model.StringArray{"test1.go", "test2.jpg"}
- if message := getMessageForNotification(post, translateFunc); message != "2 files sent: test1.go, test2.jpg" {
+ store.Must(Srv.Store.FileInfo().AttachToPost(testJpg2.Id, post.Id))
+ post.FileIds = model.StringArray{testFile.Id, testJpg2.Id}
+ if message := getMessageForNotification(post, translateFunc); message != "2 files sent: test1.go, test3.jpg" {
t.Fatal("should've returned number of mixed files:", message)
}
}
+
+func TestGetFileInfosForPost(t *testing.T) {
+ th := Setup().InitBasic()
+ Client := th.BasicClient
+ channel1 := th.BasicChannel
+
+ fileIds := make([]string, 3, 3)
+ if data, err := readTestFile("test.png"); err != nil {
+ t.Fatal(err)
+ } else {
+ for i := 0; i < 3; i++ {
+ fileIds[i] = Client.MustGeneric(Client.UploadPostAttachment(data, channel1.Id, "test.png")).(*model.FileUploadResponse).FileInfos[0].Id
+ }
+ }
+
+ post1 := Client.Must(Client.CreatePost(&model.Post{
+ ChannelId: channel1.Id,
+ Message: "test",
+ FileIds: fileIds,
+ })).Data.(*model.Post)
+
+ var etag string
+ if infos, err := Client.GetFileInfosForPost(channel1.Id, post1.Id, ""); err != nil {
+ t.Fatal(err)
+ } else if len(infos) != 3 {
+ t.Fatal("should've received 3 files")
+ } else if Client.Etag == "" {
+ t.Fatal("should've received etag")
+ } else {
+ etag = Client.Etag
+ }
+
+ if infos, err := Client.GetFileInfosForPost(channel1.Id, post1.Id, etag); err != nil {
+ t.Fatal(err)
+ } else if len(infos) != 0 {
+ t.Fatal("should've returned nothing because of etag")
+ }
+}
diff --git a/i18n/en.json b/i18n/en.json
index 63aa5006a..7bfab85f6 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -830,14 +830,6 @@
"translation": "Unable to create emoji. Image must be at most 128 by 128 pixels."
},
{
- "id": "api.file.file_upload.exceeds",
- "translation": "File exceeds max image size."
- },
- {
- "id": "api.file.get_file.not_found.app_error",
- "translation": "Could not find file."
- },
- {
"id": "api.file.get_file.public_disabled.app_error",
"translation": "Public links have been disabled by the system administrator"
},
@@ -850,30 +842,98 @@
"translation": "Public links have been disabled"
},
{
+ "id": "api.file.get_public_link.no_post.app_error",
+ "translation": "Unable to get public link for file. File must be attached to a post that can be read by the current user."
+ },
+ {
+ "id": "api.file.get_file_preview.no_thumbnail.app_error",
+ "translation": "File doesn't have a preview image"
+ },
+ {
+ "id": "api.file.get_file_thumbnail.no_thumbnail.app_error",
+ "translation": "File doesn't have a thumbnail image"
+ },
+ {
+ "id": "api.file.get_public_file_old.storage.app_error",
+ "translation": "Unable to get file. Image storage is not configured."
+ },
+ {
+ "id": "api.file.get_public_file_old.storage.app_error",
+ "translation": "Unable to get file. Image storage is not configured."
+ },
+ {
+ "id": "api.file.get_info_for_request.no_post.app_error",
+ "translation": "Unable to get info for file. File must be attached to a post that can be read by the current user."
+ },
+ {
+ "id": "api.file.get_info_for_request.storage.app_error",
+ "translation": "Unable to get info for file. Image storage is not configured."
+ },
+ {
"id": "api.file.handle_images_forget.decode.error",
- "translation": "Unable to decode image channelId=%v userId=%v filename=%v err=%v"
+ "translation": "Unable to decode image err=%v"
},
{
"id": "api.file.handle_images_forget.encode_jpeg.error",
- "translation": "Unable to encode image as jpeg channelId=%v userId=%v filename=%v err=%v"
+ "translation": "Unable to encode image as jpeg path=%v err=%v"
},
{
"id": "api.file.handle_images_forget.encode_preview.error",
- "translation": "Unable to encode image as preview jpg channelId=%v userId=%v filename=%v err=%v"
+ "translation": "Unable to encode image as preview jpg path=%v err=%v"
},
{
"id": "api.file.handle_images_forget.upload_preview.error",
- "translation": "Unable to upload preview channelId=%v userId=%v filename=%v err=%v"
+ "translation": "Unable to upload preview path=%v err=%v"
},
{
"id": "api.file.handle_images_forget.upload_thumb.error",
- "translation": "Unable to upload thumbnail channelId=%v userId=%v filename=%v err=%v"
+ "translation": "Unable to upload thumbnail path=%v err=%v"
},
{
"id": "api.file.init.debug",
"translation": "Initializing file api routes"
},
{
+ "id": "api.file.migrate_filenames_to_file_infos.channel.app_error",
+ "translation": "Unable to get channel when migrating post to use FileInfos, post_id=%v, channel_id=%v, err=%v"
+ },
+ {
+ "id": "api.file.migrate_filenames_to_file_infos.file_not_found.warn",
+ "translation": "Unable to find file when migrating post to use FileInfos, post_id=%v, filename=%v, path=%v, err=%v"
+ },
+ {
+ "id": "api.file.migrate_filenames_to_file_infos.info.app_error",
+ "translation": "Unable to fully decode file info when migrating post to use FileInfos, post_id=%v, filename=%v, err=%v"
+ },
+ {
+ "id": "api.file.migrate_filenames_to_file_infos.mismatched_filename.warn",
+ "translation": "Found an unusual filename when migrating post to use FileInfos, post_id=%v, channel_id=%v, user_id=%v, filename=%v"
+ },
+ {
+ "id": "api.file.migrate_filenames_to_file_infos.no_filenames.warn",
+ "translation": "Unable to migrate post to use FileInfos with an empty Filenames field, post_id=%v"
+ },
+ {
+ "id": "api.file.migrate_filenames_to_file_infos.save_file_info.warn",
+ "translation": "Unable to save post when migrating post to use FileInfos, post_id=%v, new_file_ids=%v, old_filenames=%v, err=%v"
+ },
+ {
+ "id": "api.file.migrate_filenames_to_file_infos.save_post.warn",
+ "translation": "Unable to save file info when migrating post to use FileInfos, post_id=%v, file_id=%v, filename=%v, err=%v"
+ },
+ {
+ "id": "api.file.migrate_filenames_to_file_infos.team_id.app_error",
+ "translation": "Unable to find team for FileInfos, post_id=%v, filenames=%v"
+ },
+ {
+ "id": "api.file.migrate_filenames_to_file_infos.teams.app_error",
+ "translation": "Unable to get teams when migrating post to use FileInfos, post_id=%v, err=%v"
+ },
+ {
+ "id": "api.file.migrate_filenames_to_file_infos.unexpected_filename.error",
+ "translation": "Unable to decipher filename when migrating post to use FileInfos, post_id=%v, filename=%v"
+ },
+ {
"id": "api.file.move_file.configured.app_error",
"translation": "File storage not configured properly. Please configure for either S3 or local server file storage."
},
@@ -918,22 +978,6 @@
"translation": "Encountered an error reading from local server storage"
},
{
- "id": "api.file.upload_file.image.app_error",
- "translation": "Unable to upload image file."
- },
- {
- "id": "api.file.upload_file.large_image.app_error",
- "translation": "Unable to upload image file. File is too large."
- },
- {
- "id": "api.file.upload_file.storage.app_error",
- "translation": "Unable to upload file. Image storage is not configured."
- },
- {
- "id": "api.file.upload_file.too_large.app_error",
- "translation": "Unable to upload file. File is too large."
- },
- {
"id": "api.file.write_file.configured.app_error",
"translation": "File storage not configured properly. Please configure for either S3 or local server file storage."
},
@@ -950,6 +994,18 @@
"translation": "Encountered an error writing to local server storage"
},
{
+ "id": "api.file.upload_file.large_image.app_error",
+ "translation": "Unable to upload image file. File is too large."
+ },
+ {
+ "id": "api.file.upload_file.storage.app_error",
+ "translation": "Unable to upload file. Image storage is not configured."
+ },
+ {
+ "id": "api.file.upload_file.too_large.app_error",
+ "translation": "Unable to upload file. File is too large."
+ },
+ {
"id": "api.general.init.debug",
"translation": "Initializing general api routes"
},
@@ -1162,6 +1218,10 @@
"translation": "{{.Username}} was mentioned, but they did not receive a notification because they do not belong to this channel."
},
{
+ "id": "api.post.create_post.attach_files.error",
+ "translation": "Encountered error attaching files to post, post_id=%s, user_id=%s, file_ids=%v, err=%v"
+ },
+ {
"id": "api.post.create_post.bad_filename.error",
"translation": "Bad filename discarded, filename=%v"
},
@@ -1198,6 +1258,14 @@
"translation": "You do not have the appropriate permissions"
},
{
+ "id": "api.post.delete_post_files.app_error.warn",
+ "translation": "Encountered error when deleting files for post, post_id=%v, err=%v"
+ },
+ {
+ "id": "api.post.get_message_for_notification.get_files.error",
+ "translation": "Encountered error when getting files for notification message, post_id=%v, err=%v"
+ },
+ {
"id": "api.post.get_message_for_notification.files_sent",
"translation": {
"one": "{{.Count}} file sent: {{.Filenames}}",
@@ -1288,6 +1356,10 @@
"translation": "Failed to retrieve comment thread posts in notifications root_post_id=%v, err=%v"
},
{
+ "id": "api.post.send_notifications_and_forget.files.error",
+ "translation": "Failed to get files for post notification post_id=%v, err=%v"
+ },
+ {
"id": "api.post.send_notifications_and_forget.get_teams.error",
"translation": "Failed to get teams when sending cross-team DM user_id=%v, err=%v"
},
@@ -3424,6 +3496,10 @@
"translation": "Invalid filenames"
},
{
+ "id": "model.post.is_valid.file_ids.app_error",
+ "translation": "Invalid file ids"
+ },
+ {
"id": "model.post.is_valid.hashtags.app_error",
"translation": "Invalid hashtags"
},
@@ -3876,6 +3952,10 @@
"translation": "We couldn't get the extra info for channel members"
},
{
+ "id": "store.sql_channel.get_for_post.app_error",
+ "translation": "We couldn't get the channel for the given post"
+ },
+ {
"id": "store.sql_channel.get_member.app_error",
"translation": "We couldn't get the channel member"
},
@@ -3888,6 +3968,10 @@
"translation": "We couldn't get the channel member count"
},
{
+ "id": "store.sql_channel.get_member_for_post.app_error",
+ "translation": "We couldn't get the channel member for the given post"
+ },
+ {
"id": "store.sql_channel.get_members.app_error",
"translation": "We couldn't get the channel members"
},
@@ -4072,6 +4156,30 @@
"translation": "We couldn't save the emoji"
},
{
+ "id": "store.sql_file_info.attach_to_post.app_error",
+ "translation": "We couldn't attach the file info to the post"
+ },
+ {
+ "id": "store.sql_file_info.delete_for_post.app_error",
+ "translation": "We couldn't delete the file info to the post"
+ },
+ {
+ "id": "store.sql_file_info.get.app_error",
+ "translation": "We couldn't get the file info"
+ },
+ {
+ "id": "store.sql_file_info.get_by_path.app_error",
+ "translation": "We couldn't get the file info by path"
+ },
+ {
+ "id": "store.sql_file_info.get_for_post.app_error",
+ "translation": "We couldn't get the file info for the post"
+ },
+ {
+ "id": "store.sql_file_info.save.app_error",
+ "translation": "We couldn't save the file info"
+ },
+ {
"id": "store.sql_license.get.app_error",
"translation": "We encountered an error getting the license"
},
diff --git a/model/client.go b/model/client.go
index 1048239be..f9a56b86e 100644
--- a/model/client.go
+++ b/model/client.go
@@ -124,6 +124,10 @@ func (c *Client) GetGeneralRoute() string {
return "/general"
}
+func (c *Client) GetFileRoute(fileId string) string {
+ return fmt.Sprintf("/files/%v", fileId)
+}
+
func (c *Client) DoPost(url, data, contentType string) (*http.Response, *AppError) {
rq, _ := http.NewRequest("POST", c.Url+url, strings.NewReader(data))
rq.Header.Set("Content-Type", contentType)
@@ -1289,8 +1293,33 @@ func (c *Client) UploadProfileFile(data []byte, contentType string) (*Result, *A
return c.uploadFile(c.ApiUrl+"/users/newimage", data, contentType)
}
-func (c *Client) UploadPostAttachment(data []byte, contentType string) (*Result, *AppError) {
- return c.uploadFile(c.ApiUrl+c.GetTeamRoute()+"/files/upload", data, contentType)
+func (c *Client) UploadPostAttachment(data []byte, channelId string, filename string) (*FileUploadResponse, *AppError) {
+ c.clearExtraProperties()
+
+ body := &bytes.Buffer{}
+ writer := multipart.NewWriter(body)
+
+ if part, err := writer.CreateFormFile("files", filename); err != nil {
+ return nil, NewLocAppError("UploadPostAttachment", "model.client.upload_post_attachment.file.app_error", nil, err.Error())
+ } else if _, err = io.Copy(part, bytes.NewBuffer(data)); err != nil {
+ return nil, NewLocAppError("UploadPostAttachment", "model.client.upload_post_attachment.file.app_error", nil, err.Error())
+ }
+
+ if part, err := writer.CreateFormField("channel_id"); err != nil {
+ return nil, NewLocAppError("UploadPostAttachment", "model.client.upload_post_attachment.channel_id.app_error", nil, err.Error())
+ } else if _, err = io.Copy(part, strings.NewReader(channelId)); err != nil {
+ return nil, NewLocAppError("UploadPostAttachment", "model.client.upload_post_attachment.channel_id.app_error", nil, err.Error())
+ }
+
+ if err := writer.Close(); err != nil {
+ return nil, NewLocAppError("UploadPostAttachment", "model.client.upload_post_attachment.writer.app_error", nil, err.Error())
+ }
+
+ if result, err := c.uploadFile(c.ApiUrl+c.GetTeamRoute()+"/files/upload", body.Bytes(), writer.FormDataContentType()); err != nil {
+ return nil, err
+ } else {
+ return result.Data.(*FileUploadResponse), nil
+ }
}
func (c *Client) uploadFile(url string, data []byte, contentType string) (*Result, *AppError) {
@@ -1312,55 +1341,51 @@ func (c *Client) uploadFile(url string, data []byte, contentType string) (*Resul
}
}
-func (c *Client) GetFile(url string, isFullUrl bool) (*Result, *AppError) {
- var rq *http.Request
- if isFullUrl {
- rq, _ = http.NewRequest("GET", url, nil)
+func (c *Client) GetFile(fileId string) (io.ReadCloser, *AppError) {
+ if r, err := c.DoApiGet(c.GetFileRoute(fileId)+"/get", "", ""); err != nil {
+ return nil, err
} else {
- rq, _ = http.NewRequest("GET", c.ApiUrl+c.GetTeamRoute()+"/files/get"+url, nil)
- }
-
- if len(c.AuthToken) > 0 {
- rq.Header.Set(HEADER_AUTH, "BEARER "+c.AuthToken)
+ c.fillInExtraProperties(r)
+ return r.Body, nil
}
+}
- if rp, err := c.HttpClient.Do(rq); err != nil {
- return nil, NewLocAppError(url, "model.client.connecting.app_error", nil, err.Error())
- } else if rp.StatusCode >= 300 {
- return nil, AppErrorFromJson(rp.Body)
+func (c *Client) GetFileThumbnail(fileId string) (io.ReadCloser, *AppError) {
+ if r, err := c.DoApiGet(c.GetFileRoute(fileId)+"/get_thumbnail", "", ""); err != nil {
+ return nil, err
} else {
- defer closeBody(rp)
- return &Result{rp.Header.Get(HEADER_REQUEST_ID),
- rp.Header.Get(HEADER_ETAG_SERVER), rp.Body}, nil
+ c.fillInExtraProperties(r)
+ return r.Body, nil
}
}
-func (c *Client) GetFileInfo(url string) (*Result, *AppError) {
- var rq *http.Request
- rq, _ = http.NewRequest("GET", c.ApiUrl+c.GetTeamRoute()+"/files/get_info"+url, nil)
-
- if len(c.AuthToken) > 0 {
- rq.Header.Set(HEADER_AUTH, "BEARER "+c.AuthToken)
+func (c *Client) GetFilePreview(fileId string) (io.ReadCloser, *AppError) {
+ if r, err := c.DoApiGet(c.GetFileRoute(fileId)+"/get_preview", "", ""); err != nil {
+ return nil, err
+ } else {
+ defer closeBody(r)
+ c.fillInExtraProperties(r)
+ return r.Body, nil
}
+}
- if rp, err := c.HttpClient.Do(rq); err != nil {
- return nil, NewLocAppError(url, "model.client.connecting.app_error", nil, err.Error())
- } else if rp.StatusCode >= 300 {
- return nil, AppErrorFromJson(rp.Body)
+func (c *Client) GetFileInfo(fileId string) (*FileInfo, *AppError) {
+ if r, err := c.DoApiGet(c.GetFileRoute(fileId)+"/get_info", "", ""); err != nil {
+ return nil, err
} else {
- defer closeBody(rp)
- return &Result{rp.Header.Get(HEADER_REQUEST_ID),
- rp.Header.Get(HEADER_ETAG_SERVER), FileInfoFromJson(rp.Body)}, nil
+ defer closeBody(r)
+ c.fillInExtraProperties(r)
+ return FileInfoFromJson(r.Body), nil
}
}
-func (c *Client) GetPublicLink(filename string) (*Result, *AppError) {
- if r, err := c.DoApiPost(c.GetTeamRoute()+"/files/get_public_link", MapToJson(map[string]string{"filename": filename})); err != nil {
- return nil, err
+func (c *Client) GetPublicLink(fileId string) (string, *AppError) {
+ if r, err := c.DoApiGet(c.GetFileRoute(fileId)+"/get_public_link", "", ""); err != nil {
+ return "", err
} else {
defer closeBody(r)
- return &Result{r.Header.Get(HEADER_REQUEST_ID),
- r.Header.Get(HEADER_ETAG_SERVER), StringFromJson(r.Body)}, nil
+ c.fillInExtraProperties(r)
+ return StringFromJson(r.Body), nil
}
}
@@ -1930,3 +1955,17 @@ func (c *Client) GetWebrtcToken() (map[string]string, *AppError) {
return MapFromJson(r.Body), nil
}
}
+
+// GetFileInfosForPost returns a list of FileInfo objects for a given post id, if successful.
+// Otherwise, it returns an error.
+func (c *Client) GetFileInfosForPost(channelId string, postId string, etag string) ([]*FileInfo, *AppError) {
+ c.clearExtraProperties()
+
+ if r, err := c.DoApiGet(c.GetChannelRoute(channelId)+fmt.Sprintf("/posts/%v/get_file_infos", postId), "", etag); err != nil {
+ return nil, err
+ } else {
+ defer closeBody(r)
+ c.fillInExtraProperties(r)
+ return FileInfosFromJson(r.Body), nil
+ }
+}
diff --git a/model/compliance_post.go b/model/compliance_post.go
index ce26a3660..027e534b7 100644
--- a/model/compliance_post.go
+++ b/model/compliance_post.go
@@ -34,7 +34,7 @@ type CompliancePost struct {
PostType string
PostProps string
PostHashtags string
- PostFilenames string
+ PostFileIds string
}
func CompliancePostHeader() []string {
@@ -60,7 +60,7 @@ func CompliancePostHeader() []string {
"PostType",
"PostProps",
"PostHashtags",
- "PostFilenames",
+ "PostFileIds",
}
}
@@ -99,6 +99,6 @@ func (me *CompliancePost) Row() []string {
me.PostType,
me.PostProps,
me.PostHashtags,
- me.PostFilenames,
+ me.PostFileIds,
}
}
diff --git a/model/compliance_post_test.go b/model/compliance_post_test.go
index 28e20ba4b..49f41a121 100644
--- a/model/compliance_post_test.go
+++ b/model/compliance_post_test.go
@@ -14,7 +14,7 @@ func TestCompliancePostHeader(t *testing.T) {
}
func TestCompliancePost(t *testing.T) {
- o := CompliancePost{TeamName: "test", PostFilenames: "files", PostCreateAt: GetMillis()}
+ o := CompliancePost{TeamName: "test", PostFileIds: "files", PostCreateAt: GetMillis()}
r := o.Row()
if r[0] != "test" {
diff --git a/model/file.go b/model/file.go
index fa98a3b3a..c218c4246 100644
--- a/model/file.go
+++ b/model/file.go
@@ -14,8 +14,8 @@ var (
)
type FileUploadResponse struct {
- Filenames []string `json:"filenames"`
- ClientIds []string `json:"client_ids"`
+ FileInfos []*FileInfo `json:"file_infos"`
+ ClientIds []string `json:"client_ids"`
}
func FileUploadResponseFromJson(data io.Reader) *FileUploadResponse {
diff --git a/model/file_info.go b/model/file_info.go
index f785042b3..687473d4f 100644
--- a/model/file_info.go
+++ b/model/file_info.go
@@ -6,58 +6,55 @@ package model
import (
"bytes"
"encoding/json"
+ "image"
"image/gif"
"io"
"mime"
"path/filepath"
+ "strings"
)
type FileInfo struct {
- Filename string `json:"filename"`
- Size int `json:"size"`
+ Id string `json:"id"`
+ CreatorId string `json:"user_id"`
+ PostId string `json:"post_id,omitempty"`
+ CreateAt int64 `json:"create_at"`
+ UpdateAt int64 `json:"update_at"`
+ DeleteAt int64 `json:"delete_at"`
+ Path string `json:"-"` // not sent back to the client
+ ThumbnailPath string `json:"-"` // not sent back to the client
+ PreviewPath string `json:"-"` // not sent back to the client
+ Name string `json:"name"`
Extension string `json:"extension"`
+ Size int64 `json:"size"`
MimeType string `json:"mime_type"`
- HasPreviewImage bool `json:"has_preview_image"`
+ Width int `json:"width,omitempty"`
+ Height int `json:"height,omitempty"`
+ HasPreviewImage bool `json:"has_preview_image,omitempty"`
}
-func GetInfoForBytes(filename string, data []byte) (*FileInfo, *AppError) {
- size := len(data)
-
- var mimeType string
- extension := filepath.Ext(filename)
- isImage := IsFileExtImage(extension)
- if isImage {
- mimeType = GetImageMimeType(extension)
+func (info *FileInfo) ToJson() string {
+ b, err := json.Marshal(info)
+ if err != nil {
+ return ""
} else {
- mimeType = mime.TypeByExtension(extension)
+ return string(b)
}
+}
- if extension != "" && extension[0] == '.' {
- // the client expects a file extension without the leading period
- extension = extension[1:]
- }
+func FileInfoFromJson(data io.Reader) *FileInfo {
+ decoder := json.NewDecoder(data)
- hasPreviewImage := isImage
- if mimeType == "image/gif" {
- // just show the gif itself instead of a preview image for animated gifs
- if gifImage, err := gif.DecodeAll(bytes.NewReader(data)); err != nil {
- return nil, NewLocAppError("GetInfoForBytes", "model.file_info.get.gif.app_error", nil, "filename="+filename)
- } else {
- hasPreviewImage = len(gifImage.Image) == 1
- }
+ var info FileInfo
+ if err := decoder.Decode(&info); err != nil {
+ return nil
+ } else {
+ return &info
}
-
- return &FileInfo{
- Filename: filename,
- Size: size,
- Extension: extension,
- MimeType: mimeType,
- HasPreviewImage: hasPreviewImage,
- }, nil
}
-func (info *FileInfo) ToJson() string {
- b, err := json.Marshal(info)
+func FileInfosToJson(infos []*FileInfo) string {
+ b, err := json.Marshal(infos)
if err != nil {
return ""
} else {
@@ -65,13 +62,113 @@ func (info *FileInfo) ToJson() string {
}
}
-func FileInfoFromJson(data io.Reader) *FileInfo {
+func FileInfosFromJson(data io.Reader) []*FileInfo {
decoder := json.NewDecoder(data)
- var info FileInfo
- if err := decoder.Decode(&info); err != nil {
+ var infos []*FileInfo
+ if err := decoder.Decode(&infos); err != nil {
return nil
} else {
- return &info
+ return infos
+ }
+}
+
+func (o *FileInfo) PreSave() {
+ if o.Id == "" {
+ o.Id = NewId()
+ }
+
+ if o.CreateAt == 0 {
+ o.CreateAt = GetMillis()
+ o.UpdateAt = o.CreateAt
+ }
+}
+
+func (o *FileInfo) IsValid() *AppError {
+ if len(o.Id) != 26 {
+ return NewLocAppError("FileInfo.IsValid", "model.file_info.is_valid.id.app_error", nil, "")
+ }
+
+ if len(o.CreatorId) != 26 {
+ return NewLocAppError("FileInfo.IsValid", "model.file_info.is_valid.user_id.app_error", nil, "id="+o.Id)
+ }
+
+ if len(o.PostId) != 0 && len(o.PostId) != 26 {
+ return NewLocAppError("FileInfo.IsValid", "model.file_info.is_valid.post_id.app_error", nil, "id="+o.Id)
+ }
+
+ if o.CreateAt == 0 {
+ return NewLocAppError("FileInfo.IsValid", "model.file_info.is_valid.create_at.app_error", nil, "id="+o.Id)
+ }
+
+ if o.UpdateAt == 0 {
+ return NewLocAppError("FileInfo.IsValid", "model.file_info.is_valid.update_at.app_error", nil, "id="+o.Id)
+ }
+
+ if o.Path == "" {
+ return NewLocAppError("FileInfo.IsValid", "model.file_info.is_valid.path.app_error", nil, "id="+o.Id)
}
+
+ return nil
+}
+
+func (o *FileInfo) IsImage() bool {
+ return strings.HasPrefix(o.MimeType, "image")
+}
+
+func GetInfoForBytes(name string, data []byte) (*FileInfo, *AppError) {
+ info := &FileInfo{
+ Name: name,
+ Size: int64(len(data)),
+ }
+ var err *AppError
+
+ 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
+ }
+
+ if info.IsImage() {
+ // Only set the width and height if it's actually an image that we can understand
+ if config, _, err := image.DecodeConfig(bytes.NewReader(data)); err == nil {
+ info.Width = config.Width
+ info.Height = config.Height
+
+ if info.MimeType == "image/gif" {
+ // Just show the gif itself instead of a preview image for animated gifs
+ if gifConfig, err := gif.DecodeAll(bytes.NewReader(data)); err != nil {
+ // Still return the rest of the info even though it doesn't appear to be an actual gif
+ info.HasPreviewImage = true
+ err = NewLocAppError("GetInfoForBytes", "model.file_info.get.gif.app_error", nil, "name="+name)
+ } else {
+ info.HasPreviewImage = len(gifConfig.Image) == 1
+ }
+ } else {
+ info.HasPreviewImage = true
+ }
+ }
+ }
+
+ return info, err
+}
+
+func GetEtagForFileInfos(infos []*FileInfo) string {
+ if len(infos) == 0 {
+ return Etag()
+ }
+
+ var maxUpdateAt int64
+
+ for _, info := range infos {
+ if info.UpdateAt > maxUpdateAt {
+ maxUpdateAt = info.UpdateAt
+ }
+ }
+
+ return Etag(infos[0].PostId, maxUpdateAt)
}
diff --git a/model/file_info_test.go b/model/file_info_test.go
index 90256aed7..d3671f252 100644
--- a/model/file_info_test.go
+++ b/model/file_info_test.go
@@ -5,56 +5,137 @@ package model
import (
"encoding/base64"
+ _ "image/gif"
+ _ "image/png"
"io/ioutil"
"strings"
"testing"
)
-func TestGetInfoForBytes(t *testing.T) {
+func TestFileInfoIsValid(t *testing.T) {
+ info := &FileInfo{
+ Id: NewId(),
+ CreatorId: NewId(),
+ CreateAt: 1234,
+ UpdateAt: 1234,
+ PostId: "",
+ Path: "fake/path.png",
+ }
+
+ if err := info.IsValid(); err != nil {
+ t.Fatal(err)
+ }
+
+ info.Id = ""
+ if err := info.IsValid(); err == nil {
+ t.Fatal("empty Id isn't valid")
+ }
+
+ info.Id = NewId()
+ info.CreateAt = 0
+ if err := info.IsValid(); err == nil {
+ t.Fatal("empty CreateAt isn't valid")
+ }
+
+ info.CreateAt = 1234
+ info.UpdateAt = 0
+ if err := info.IsValid(); err == nil {
+ t.Fatal("empty UpdateAt isn't valid")
+ }
+
+ info.UpdateAt = 1234
+ info.PostId = NewId()
+ if err := info.IsValid(); err != nil {
+ t.Fatal(err)
+ }
+
+ info.Path = ""
+ if err := info.IsValid(); err == nil {
+ t.Fatal("empty Path isn't valid")
+ }
+
+ info.Path = "fake/path.png"
+ if err := info.IsValid(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestFileInfoIsImage(t *testing.T) {
+ info := &FileInfo{
+ MimeType: "image/png",
+ }
+
+ if !info.IsImage() {
+ t.Fatal("file is an image")
+ }
+
+ info.MimeType = "text/plain"
+ if info.IsImage() {
+ t.Fatal("file is not an image")
+ }
+}
+
+func TestGetInfoForFile(t *testing.T) {
fakeFile := make([]byte, 1000)
if info, err := GetInfoForBytes("file.txt", fakeFile); err != nil {
t.Fatal(err)
- } else if info.Filename != "file.txt" {
- t.Fatalf("Got incorrect filename: %v", info.Filename)
+ } else if info.Name != "file.txt" {
+ t.Fatalf("Got incorrect filename: %v", info.Name)
+ } else if info.Extension != "txt" {
+ t.Fatalf("Got incorrect extension: %v", info.Extension)
} else if info.Size != 1000 {
t.Fatalf("Got incorrect size: %v", info.Size)
- } else if info.Extension != "txt" {
- t.Fatalf("Got incorrect file extension: %v", info.Extension)
} else if !strings.HasPrefix(info.MimeType, "text/plain") {
t.Fatalf("Got incorrect mime type: %v", info.MimeType)
+ } else if info.Width != 0 {
+ t.Fatalf("Got incorrect width: %v", info.Width)
+ } else if info.Height != 0 {
+ t.Fatalf("Got incorrect height: %v", info.Height)
} else if info.HasPreviewImage {
- t.Fatalf("Got HasPreviewImage = true for non-image file")
+ t.Fatalf("Got incorrect has preview image: %v", info.HasPreviewImage)
}
- if info, err := GetInfoForBytes("file.png", fakeFile); err != nil {
+ pngFile, err := ioutil.ReadFile("../tests/test.png")
+ if err != nil {
+ t.Fatalf("Failed to load test.png: %v", err.Error())
+ }
+ if info, err := GetInfoForBytes("test.png", pngFile); err != nil {
t.Fatal(err)
- } else if info.Filename != "file.png" {
- t.Fatalf("Got incorrect filename: %v", info.Filename)
- } else if info.Size != 1000 {
- t.Fatalf("Got incorrect size: %v", info.Size)
+ } else if info.Name != "test.png" {
+ t.Fatalf("Got incorrect filename: %v", info.Name)
} else if info.Extension != "png" {
- t.Fatalf("Got incorrect file extension: %v", info.Extension)
+ t.Fatalf("Got incorrect extension: %v", info.Extension)
+ } else if info.Size != 279591 {
+ t.Fatalf("Got incorrect size: %v", info.Size)
} else if info.MimeType != "image/png" {
t.Fatalf("Got incorrect mime type: %v", info.MimeType)
+ } else if info.Width != 408 {
+ t.Fatalf("Got incorrect width: %v", info.Width)
+ } else if info.Height != 336 {
+ t.Fatalf("Got incorrect height: %v", info.Height)
} else if !info.HasPreviewImage {
- t.Fatalf("Got HasPreviewImage = false for image")
+ t.Fatalf("Got incorrect has preview image: %v", info.HasPreviewImage)
}
// base 64 encoded version of handtinywhite.gif from http://probablyprogramming.com/2009/03/15/the-tiniest-gif-ever
gifFile, _ := base64.StdEncoding.DecodeString("R0lGODlhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=")
if info, err := GetInfoForBytes("handtinywhite.gif", gifFile); err != nil {
t.Fatal(err)
- } else if info.Filename != "handtinywhite.gif" {
- t.Fatalf("Got incorrect filename: %v", info.Filename)
+ } else if info.Name != "handtinywhite.gif" {
+ t.Fatalf("Got incorrect filename: %v", info.Name)
+ } else if info.Extension != "gif" {
+ t.Fatalf("Got incorrect extension: %v", info.Extension)
} else if info.Size != 35 {
t.Fatalf("Got incorrect size: %v", info.Size)
- } else if info.Extension != "gif" {
- t.Fatalf("Got incorrect file extension: %v", info.Extension)
} else if info.MimeType != "image/gif" {
t.Fatalf("Got incorrect mime type: %v", info.MimeType)
+ } else if info.Width != 1 {
+ t.Fatalf("Got incorrect width: %v", info.Width)
+ } else if info.Height != 1 {
+ t.Fatalf("Got incorrect height: %v", info.Height)
} else if !info.HasPreviewImage {
- t.Fatalf("Got HasPreviewImage = false for static gif")
+ t.Fatalf("Got incorrect has preview image: %v", info.HasPreviewImage)
}
animatedGifFile, err := ioutil.ReadFile("../tests/testgif.gif")
@@ -63,29 +144,57 @@ func TestGetInfoForBytes(t *testing.T) {
}
if info, err := GetInfoForBytes("testgif.gif", animatedGifFile); err != nil {
t.Fatal(err)
- } else if info.Filename != "testgif.gif" {
- t.Fatalf("Got incorrect filename: %v", info.Filename)
+ } else if info.Name != "testgif.gif" {
+ t.Fatalf("Got incorrect filename: %v", info.Name)
+ } else if info.Extension != "gif" {
+ t.Fatalf("Got incorrect extension: %v", info.Extension)
} else if info.Size != 38689 {
t.Fatalf("Got incorrect size: %v", info.Size)
- } else if info.Extension != "gif" {
- t.Fatalf("Got incorrect file extension: %v", info.Extension)
} else if info.MimeType != "image/gif" {
t.Fatalf("Got incorrect mime type: %v", info.MimeType)
+ } else if info.Width != 118 {
+ t.Fatalf("Got incorrect width: %v", info.Width)
+ } else if info.Height != 118 {
+ t.Fatalf("Got incorrect height: %v", info.Height)
} else if info.HasPreviewImage {
- t.Fatalf("Got HasPreviewImage = true for animated gif")
+ t.Fatalf("Got incorrect has preview image: %v", info.HasPreviewImage)
}
if info, err := GetInfoForBytes("filewithoutextension", fakeFile); err != nil {
t.Fatal(err)
- } else if info.Filename != "filewithoutextension" {
- t.Fatalf("Got incorrect filename: %v", info.Filename)
+ } else if info.Name != "filewithoutextension" {
+ t.Fatalf("Got incorrect filename: %v", info.Name)
+ } else if info.Extension != "" {
+ t.Fatalf("Got incorrect extension: %v", info.Extension)
} else if info.Size != 1000 {
t.Fatalf("Got incorrect size: %v", info.Size)
- } else if info.Extension != "" {
- t.Fatalf("Got incorrect file extension: %v", info.Extension)
} else if info.MimeType != "" {
t.Fatalf("Got incorrect mime type: %v", info.MimeType)
+ } else if info.Width != 0 {
+ t.Fatalf("Got incorrect width: %v", info.Width)
+ } else if info.Height != 0 {
+ t.Fatalf("Got incorrect height: %v", info.Height)
} else if info.HasPreviewImage {
- t.Fatalf("Got HasPreviewImage = true for non-image file")
+ t.Fatalf("Got incorrect has preview image: %v", info.HasPreviewImage)
+ }
+
+ // Always make the extension lower case to make it easier to use in other places
+ if info, err := GetInfoForBytes("file.TXT", fakeFile); err != nil {
+ t.Fatal(err)
+ } else if info.Name != "file.TXT" {
+ t.Fatalf("Got incorrect filename: %v", info.Name)
+ } else if info.Extension != "txt" {
+ t.Fatalf("Got incorrect extension: %v", info.Extension)
+ }
+
+ // Don't error out for image formats we don't support
+ if info, err := GetInfoForBytes("file.tif", fakeFile); err != nil {
+ t.Fatal(err)
+ } else if info.Name != "file.tif" {
+ t.Fatalf("Got incorrect filename: %v", info.Name)
+ } else if info.Extension != "tif" {
+ t.Fatalf("Got incorrect extension: %v", info.Extension)
+ } else if info.MimeType != "image/tiff" && info.MimeType != "image/x-tiff" {
+ t.Fatalf("Got incorrect mime type: %v", info.MimeType)
}
}
diff --git a/model/post.go b/model/post.go
index 33caeb9ea..da14b650f 100644
--- a/model/post.go
+++ b/model/post.go
@@ -35,7 +35,8 @@ type Post struct {
Type string `json:"type"`
Props StringInterface `json:"props"`
Hashtags string `json:"hashtags"`
- Filenames StringArray `json:"filenames"`
+ Filenames StringArray `json:"filenames,omitempty"` // Deprecated, do not use this field any more
+ FileIds StringArray `json:"file_ids,omitempty"`
PendingPostId string `json:"pending_post_id" db:"-"`
}
@@ -118,6 +119,10 @@ func (o *Post) IsValid() *AppError {
return NewLocAppError("Post.IsValid", "model.post.is_valid.filenames.app_error", nil, "id="+o.Id)
}
+ if utf8.RuneCountInString(ArrayToJson(o.FileIds)) > 150 {
+ return NewLocAppError("Post.IsValid", "model.post.is_valid.file_ids.app_error", nil, "id="+o.Id)
+ }
+
if utf8.RuneCountInString(StringInterfaceToJson(o.Props)) > 8000 {
return NewLocAppError("Post.IsValid", "model.post.is_valid.props.app_error", nil, "id="+o.Id)
}
@@ -145,15 +150,16 @@ func (o *Post) PreSave() {
if o.Filenames == nil {
o.Filenames = []string{}
}
+
+ if o.FileIds == nil {
+ o.FileIds = []string{}
+ }
}
func (o *Post) MakeNonNil() {
if o.Props == nil {
o.Props = make(map[string]interface{})
}
- if o.Filenames == nil {
- o.Filenames = []string{}
- }
}
func (o *Post) AddProp(key string, value interface{}) {
diff --git a/store/sql_channel_store.go b/store/sql_channel_store.go
index 99a36b1cd..07c037075 100644
--- a/store/sql_channel_store.go
+++ b/store/sql_channel_store.go
@@ -596,6 +596,36 @@ func (s SqlChannelStore) GetMember(channelId string, userId string) StoreChannel
return storeChannel
}
+func (s SqlChannelStore) GetMemberForPost(postId string, userId string) StoreChannel {
+ storeChannel := make(StoreChannel, 1)
+
+ go func() {
+ result := StoreResult{}
+
+ member := &model.ChannelMember{}
+ if err := s.GetReplica().SelectOne(
+ member,
+ `SELECT
+ ChannelMembers.*
+ FROM
+ ChannelMembers,
+ Posts
+ WHERE
+ ChannelMembers.ChannelId = Posts.ChannelId
+ AND ChannelMembers.UserId = :UserId
+ AND Posts.Id = :PostId`, map[string]interface{}{"UserId": userId, "PostId": postId}); err != nil {
+ result.Err = model.NewLocAppError("SqlChannelStore.GetMemberForPost", "store.sql_channel.get_member_for_post.app_error", nil, "postId="+postId+", err="+err.Error())
+ } else {
+ result.Data = member
+ }
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
+
func (s SqlChannelStore) GetMemberCount(channelId string) StoreChannel {
storeChannel := make(StoreChannel, 1)
@@ -878,6 +908,35 @@ func (s SqlChannelStore) GetAll(teamId string) StoreChannel {
return storeChannel
}
+func (s SqlChannelStore) GetForPost(postId string) StoreChannel {
+ storeChannel := make(StoreChannel, 1)
+
+ go func() {
+ result := StoreResult{}
+
+ channel := &model.Channel{}
+ if err := s.GetReplica().SelectOne(
+ channel,
+ `SELECT
+ Channels.*
+ FROM
+ Channels,
+ Posts
+ WHERE
+ Channels.Id = Posts.ChannelId
+ AND Posts.Id = :PostId`, map[string]interface{}{"PostId": postId}); err != nil {
+ result.Err = model.NewLocAppError("SqlChannelStore.GetForPost", "store.sql_channel.get_for_post.app_error", nil, "postId="+postId+", err="+err.Error())
+ } else {
+ result.Data = channel
+ }
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
+
func (s SqlChannelStore) AnalyticsTypeCount(teamId string, channelType string) StoreChannel {
storeChannel := make(StoreChannel, 1)
diff --git a/store/sql_channel_store_test.go b/store/sql_channel_store_test.go
index d7d99f581..0bd059e5f 100644
--- a/store/sql_channel_store_test.go
+++ b/store/sql_channel_store_test.go
@@ -197,6 +197,29 @@ func TestChannelStoreGet(t *testing.T) {
}
}
+func TestChannelStoreGetForPost(t *testing.T) {
+ Setup()
+
+ o1 := Must(store.Channel().Save(&model.Channel{
+ TeamId: model.NewId(),
+ DisplayName: "Name",
+ Name: "a" + model.NewId() + "b",
+ Type: model.CHANNEL_OPEN,
+ })).(*model.Channel)
+
+ p1 := Must(store.Post().Save(&model.Post{
+ UserId: model.NewId(),
+ ChannelId: o1.Id,
+ Message: "test",
+ })).(*model.Post)
+
+ if r1 := <-store.Channel().GetForPost(p1.Id); r1.Err != nil {
+ t.Fatal(r1.Err)
+ } else if r1.Data.(*model.Channel).Id != o1.Id {
+ t.Fatal("incorrect channel returned")
+ }
+}
+
func TestChannelStoreDelete(t *testing.T) {
Setup()
@@ -745,6 +768,39 @@ func TestGetMember(t *testing.T) {
}
}
+func TestChannelStoreGetMemberForPost(t *testing.T) {
+ Setup()
+
+ o1 := Must(store.Channel().Save(&model.Channel{
+ TeamId: model.NewId(),
+ DisplayName: "Name",
+ Name: "a" + model.NewId() + "b",
+ Type: model.CHANNEL_OPEN,
+ })).(*model.Channel)
+
+ m1 := Must(store.Channel().SaveMember(&model.ChannelMember{
+ ChannelId: o1.Id,
+ UserId: model.NewId(),
+ NotifyProps: model.GetDefaultChannelNotifyProps(),
+ })).(*model.ChannelMember)
+
+ p1 := Must(store.Post().Save(&model.Post{
+ UserId: model.NewId(),
+ ChannelId: o1.Id,
+ Message: "test",
+ })).(*model.Post)
+
+ if r1 := <-store.Channel().GetMemberForPost(p1.Id, m1.UserId); r1.Err != nil {
+ t.Fatal(r1.Err)
+ } else if r1.Data.(*model.ChannelMember).ToJson() != m1.ToJson() {
+ t.Fatal("invalid returned channel member")
+ }
+
+ if r2 := <-store.Channel().GetMemberForPost(p1.Id, model.NewId()); r2.Err == nil {
+ t.Fatal("shouldn't have returned a member")
+ }
+}
+
func TestGetMemberCount(t *testing.T) {
Setup()
diff --git a/store/sql_compliance_store.go b/store/sql_compliance_store.go
index 6aaef856d..0a131d289 100644
--- a/store/sql_compliance_store.go
+++ b/store/sql_compliance_store.go
@@ -199,7 +199,7 @@ func (s SqlComplianceStore) ComplianceExport(job *model.Compliance) StoreChannel
Posts.Type AS PostType,
Posts.Props AS PostProps,
Posts.Hashtags AS PostHashtags,
- Posts.Filenames AS PostFilenames
+ Posts.FileIds AS PostFileIds
FROM
Teams,
Channels,
diff --git a/store/sql_file_info_store.go b/store/sql_file_info_store.go
new file mode 100644
index 000000000..5c3f6b1a4
--- /dev/null
+++ b/store/sql_file_info_store.go
@@ -0,0 +1,197 @@
+// See License.txt for license information.
+
+package store
+
+import (
+ "github.com/mattermost/platform/model"
+)
+
+type SqlFileInfoStore struct {
+ *SqlStore
+}
+
+func NewSqlFileInfoStore(sqlStore *SqlStore) FileInfoStore {
+ s := &SqlFileInfoStore{sqlStore}
+
+ for _, db := range sqlStore.GetAllConns() {
+ table := db.AddTableWithName(model.FileInfo{}, "FileInfo").SetKeys(false, "Id")
+ table.ColMap("Id").SetMaxSize(26)
+ table.ColMap("CreatorId").SetMaxSize(26)
+ table.ColMap("PostId").SetMaxSize(26)
+ table.ColMap("Path").SetMaxSize(512)
+ table.ColMap("ThumbnailPath").SetMaxSize(512)
+ table.ColMap("PreviewPath").SetMaxSize(512)
+ table.ColMap("Name").SetMaxSize(256)
+ table.ColMap("Extension").SetMaxSize(64)
+ table.ColMap("MimeType").SetMaxSize(256)
+ }
+
+ return s
+}
+
+func (fs SqlFileInfoStore) CreateIndexesIfNotExists() {
+}
+
+func (fs SqlFileInfoStore) Save(info *model.FileInfo) StoreChannel {
+ storeChannel := make(StoreChannel, 1)
+
+ go func() {
+ result := StoreResult{}
+
+ info.PreSave()
+ if result.Err = info.IsValid(); result.Err != nil {
+ storeChannel <- result
+ close(storeChannel)
+ return
+ }
+
+ if err := fs.GetMaster().Insert(info); err != nil {
+ result.Err = model.NewLocAppError("SqlFileInfoStore.Save", "store.sql_file_info.save.app_error", nil, err.Error())
+ } else {
+ result.Data = info
+ }
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
+
+func (fs SqlFileInfoStore) Get(id string) StoreChannel {
+ storeChannel := make(StoreChannel, 1)
+
+ go func() {
+ result := StoreResult{}
+
+ info := &model.FileInfo{}
+
+ if err := fs.GetReplica().SelectOne(info,
+ `SELECT
+ *
+ FROM
+ FileInfo
+ WHERE
+ Id = :Id
+ AND DeleteAt = 0`, map[string]interface{}{"Id": id}); err != nil {
+ result.Err = model.NewLocAppError("SqlFileInfoStore.Get", "store.sql_file_info.get.app_error", nil, "id="+id+", "+err.Error())
+ } else {
+ result.Data = info
+ }
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
+
+func (fs SqlFileInfoStore) GetByPath(path string) StoreChannel {
+ storeChannel := make(StoreChannel, 1)
+
+ go func() {
+ result := StoreResult{}
+
+ info := &model.FileInfo{}
+
+ if err := fs.GetReplica().SelectOne(info,
+ `SELECT
+ *
+ FROM
+ FileInfo
+ WHERE
+ Path = :Path
+ AND DeleteAt = 0`, map[string]interface{}{"Path": path}); err != nil {
+ result.Err = model.NewLocAppError("SqlFileInfoStore.GetByPath", "store.sql_file_info.get_by_path.app_error", nil, "path="+path+", "+err.Error())
+ } else {
+ result.Data = info
+ }
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
+
+func (fs SqlFileInfoStore) GetForPost(postId string) StoreChannel {
+ storeChannel := make(StoreChannel, 1)
+
+ go func() {
+ result := StoreResult{}
+
+ var infos []*model.FileInfo
+
+ if _, err := fs.GetReplica().Select(&infos,
+ `SELECT
+ *
+ FROM
+ FileInfo
+ WHERE
+ PostId = :PostId
+ AND DeleteAt = 0
+ ORDER BY
+ CreateAt`, map[string]interface{}{"PostId": postId}); err != nil {
+ result.Err = model.NewLocAppError("SqlFileInfoStore.GetForPost",
+ "store.sql_file_info.get_for_post.app_error", nil, "post_id="+postId+", "+err.Error())
+ } else {
+ result.Data = infos
+ }
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
+
+func (fs SqlFileInfoStore) AttachToPost(fileId, postId string) StoreChannel {
+ storeChannel := make(StoreChannel, 1)
+
+ go func() {
+ result := StoreResult{}
+
+ if _, err := fs.GetMaster().Exec(
+ `UPDATE
+ FileInfo
+ SET
+ PostId = :PostId
+ WHERE
+ Id = :Id
+ AND PostId = ''`, map[string]interface{}{"PostId": postId, "Id": fileId}); err != nil {
+ result.Err = model.NewLocAppError("SqlFileInfoStore.AttachToPost",
+ "store.sql_file_info.attach_to_post.app_error", nil, "post_id="+postId+", file_id="+fileId+", err="+err.Error())
+ }
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
+
+func (fs SqlFileInfoStore) DeleteForPost(postId string) StoreChannel {
+ storeChannel := make(StoreChannel, 1)
+
+ go func() {
+ result := StoreResult{}
+
+ if _, err := fs.GetMaster().Exec(
+ `UPDATE
+ FileInfo
+ SET
+ DeleteAt = :DeleteAt
+ WHERE
+ PostId = :PostId`, map[string]interface{}{"DeleteAt": model.GetMillis(), "PostId": postId}); err != nil {
+ result.Err = model.NewLocAppError("SqlFileInfoStore.DeleteForPost",
+ "store.sql_file_info.delete_for_post.app_error", nil, "post_id="+postId+", err="+err.Error())
+ } else {
+ result.Data = postId
+ }
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
diff --git a/store/sql_file_info_store_test.go b/store/sql_file_info_store_test.go
new file mode 100644
index 000000000..edb9dbd54
--- /dev/null
+++ b/store/sql_file_info_store_test.go
@@ -0,0 +1,208 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+package store
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/mattermost/platform/model"
+)
+
+func TestFileInfoSaveGet(t *testing.T) {
+ Setup()
+
+ info := &model.FileInfo{
+ CreatorId: model.NewId(),
+ Path: "file.txt",
+ }
+
+ if result := <-store.FileInfo().Save(info); result.Err != nil {
+ t.Fatal(result.Err)
+ } else if returned := result.Data.(*model.FileInfo); len(returned.Id) == 0 {
+ t.Fatal("should've assigned an id to FileInfo")
+ } else {
+ info = returned
+ }
+
+ if result := <-store.FileInfo().Get(info.Id); result.Err != nil {
+ t.Fatal(result.Err)
+ } else if returned := result.Data.(*model.FileInfo); returned.Id != info.Id {
+ t.Log(info)
+ t.Log(returned)
+ t.Fatal("should've returned correct FileInfo")
+ }
+
+ info2 := Must(store.FileInfo().Save(&model.FileInfo{
+ CreatorId: model.NewId(),
+ Path: "file.txt",
+ DeleteAt: 123,
+ })).(*model.FileInfo)
+
+ if result := <-store.FileInfo().Get(info2.Id); result.Err == nil {
+ t.Fatal("shouldn't have gotten deleted file")
+ }
+}
+
+func TestFileInfoSaveGetByPath(t *testing.T) {
+ Setup()
+
+ info := &model.FileInfo{
+ CreatorId: model.NewId(),
+ Path: fmt.Sprintf("%v/file.txt", model.NewId()),
+ }
+
+ if result := <-store.FileInfo().Save(info); result.Err != nil {
+ t.Fatal(result.Err)
+ } else if returned := result.Data.(*model.FileInfo); len(returned.Id) == 0 {
+ t.Fatal("should've assigned an id to FileInfo")
+ } else {
+ info = returned
+ }
+
+ if result := <-store.FileInfo().GetByPath(info.Path); result.Err != nil {
+ t.Fatal(result.Err)
+ } else if returned := result.Data.(*model.FileInfo); returned.Id != info.Id {
+ t.Log(info)
+ t.Log(returned)
+ t.Fatal("should've returned correct FileInfo")
+ }
+
+ info2 := Must(store.FileInfo().Save(&model.FileInfo{
+ CreatorId: model.NewId(),
+ Path: "file.txt",
+ DeleteAt: 123,
+ })).(*model.FileInfo)
+
+ if result := <-store.FileInfo().GetByPath(info2.Id); result.Err == nil {
+ t.Fatal("shouldn't have gotten deleted file")
+ }
+}
+
+func TestFileInfoGetForPost(t *testing.T) {
+ Setup()
+
+ userId := model.NewId()
+ postId := model.NewId()
+
+ infos := []*model.FileInfo{
+ {
+ PostId: postId,
+ CreatorId: userId,
+ Path: "file.txt",
+ },
+ {
+ PostId: postId,
+ CreatorId: userId,
+ Path: "file.txt",
+ },
+ {
+ PostId: postId,
+ CreatorId: userId,
+ Path: "file.txt",
+ DeleteAt: 123,
+ },
+ {
+ PostId: model.NewId(),
+ CreatorId: userId,
+ Path: "file.txt",
+ },
+ }
+
+ for i, info := range infos {
+ infos[i] = Must(store.FileInfo().Save(info)).(*model.FileInfo)
+ }
+
+ if result := <-store.FileInfo().GetForPost(postId); result.Err != nil {
+ t.Fatal(result.Err)
+ } else if returned := result.Data.([]*model.FileInfo); len(returned) != 2 {
+ t.Fatal("should've returned exactly 2 file infos")
+ }
+}
+
+func TestFileInfoAttachToPost(t *testing.T) {
+ Setup()
+
+ userId := model.NewId()
+ postId := model.NewId()
+
+ info1 := Must(store.FileInfo().Save(&model.FileInfo{
+ CreatorId: userId,
+ Path: "file.txt",
+ })).(*model.FileInfo)
+
+ if len(info1.PostId) != 0 {
+ t.Fatal("file shouldn't have a PostId")
+ }
+
+ if result := <-store.FileInfo().AttachToPost(info1.Id, postId); result.Err != nil {
+ t.Fatal(result.Err)
+ } else {
+ info1 = Must(store.FileInfo().Get(info1.Id)).(*model.FileInfo)
+ }
+
+ if len(info1.PostId) == 0 {
+ t.Fatal("file should now have a PostId")
+ }
+
+ info2 := Must(store.FileInfo().Save(&model.FileInfo{
+ CreatorId: userId,
+ Path: "file.txt",
+ })).(*model.FileInfo)
+
+ if result := <-store.FileInfo().AttachToPost(info2.Id, postId); result.Err != nil {
+ t.Fatal(result.Err)
+ } else {
+ info2 = Must(store.FileInfo().Get(info2.Id)).(*model.FileInfo)
+ }
+
+ if result := <-store.FileInfo().GetForPost(postId); result.Err != nil {
+ t.Fatal(result.Err)
+ } else if infos := result.Data.([]*model.FileInfo); len(infos) != 2 {
+ t.Fatal("should've returned exactly 2 file infos")
+ }
+}
+
+func TestFileInfoDeleteForPost(t *testing.T) {
+ Setup()
+
+ userId := model.NewId()
+ postId := model.NewId()
+
+ infos := []*model.FileInfo{
+ {
+ PostId: postId,
+ CreatorId: userId,
+ Path: "file.txt",
+ },
+ {
+ PostId: postId,
+ CreatorId: userId,
+ Path: "file.txt",
+ },
+ {
+ PostId: postId,
+ CreatorId: userId,
+ Path: "file.txt",
+ DeleteAt: 123,
+ },
+ {
+ PostId: model.NewId(),
+ CreatorId: userId,
+ Path: "file.txt",
+ },
+ }
+
+ for i, info := range infos {
+ infos[i] = Must(store.FileInfo().Save(info)).(*model.FileInfo)
+ }
+
+ if result := <-store.FileInfo().DeleteForPost(postId); result.Err != nil {
+ t.Fatal(result.Err)
+ }
+
+ if infos := Must(store.FileInfo().GetForPost(postId)).([]*model.FileInfo); len(infos) != 0 {
+ t.Fatal("shouldn't have returned any file infos")
+ }
+}
diff --git a/store/sql_post_store.go b/store/sql_post_store.go
index 212492df0..ec8679b31 100644
--- a/store/sql_post_store.go
+++ b/store/sql_post_store.go
@@ -32,6 +32,7 @@ func NewSqlPostStore(sqlStore *SqlStore) PostStore {
table.ColMap("Hashtags").SetMaxSize(1000)
table.ColMap("Props").SetMaxSize(8000)
table.ColMap("Filenames").SetMaxSize(4000)
+ table.ColMap("FileIds").SetMaxSize(150)
}
return s
@@ -94,42 +95,39 @@ func (s SqlPostStore) Save(post *model.Post) StoreChannel {
return storeChannel
}
-func (s SqlPostStore) Update(oldPost *model.Post, newMessage string, newHashtags string) StoreChannel {
+func (s SqlPostStore) Update(newPost *model.Post, oldPost *model.Post) StoreChannel {
storeChannel := make(StoreChannel, 1)
go func() {
result := StoreResult{}
- editPost := *oldPost
- editPost.Message = newMessage
- editPost.UpdateAt = model.GetMillis()
- editPost.Hashtags = newHashtags
+ newPost.UpdateAt = model.GetMillis()
- oldPost.DeleteAt = editPost.UpdateAt
- oldPost.UpdateAt = editPost.UpdateAt
+ oldPost.DeleteAt = newPost.UpdateAt
+ oldPost.UpdateAt = newPost.UpdateAt
oldPost.OriginalId = oldPost.Id
oldPost.Id = model.NewId()
- if result.Err = editPost.IsValid(); result.Err != nil {
+ if result.Err = newPost.IsValid(); result.Err != nil {
storeChannel <- result
close(storeChannel)
return
}
- if _, err := s.GetMaster().Update(&editPost); err != nil {
- result.Err = model.NewLocAppError("SqlPostStore.Update", "store.sql_post.update.app_error", nil, "id="+editPost.Id+", "+err.Error())
+ if _, err := s.GetMaster().Update(newPost); err != nil {
+ result.Err = model.NewLocAppError("SqlPostStore.Update", "store.sql_post.update.app_error", nil, "id="+newPost.Id+", "+err.Error())
} else {
time := model.GetMillis()
- s.GetMaster().Exec("UPDATE Channels SET LastPostAt = :LastPostAt WHERE Id = :ChannelId", map[string]interface{}{"LastPostAt": time, "ChannelId": editPost.ChannelId})
+ s.GetMaster().Exec("UPDATE Channels SET LastPostAt = :LastPostAt WHERE Id = :ChannelId", map[string]interface{}{"LastPostAt": time, "ChannelId": newPost.ChannelId})
- if len(editPost.RootId) > 0 {
- s.GetMaster().Exec("UPDATE Posts SET UpdateAt = :UpdateAt WHERE Id = :RootId", map[string]interface{}{"UpdateAt": time, "RootId": editPost.RootId})
+ if len(newPost.RootId) > 0 {
+ s.GetMaster().Exec("UPDATE Posts SET UpdateAt = :UpdateAt WHERE Id = :RootId", map[string]interface{}{"UpdateAt": time, "RootId": newPost.RootId})
}
// mark the old post as deleted
s.GetMaster().Insert(oldPost)
- result.Data = &editPost
+ result.Data = newPost
}
storeChannel <- result
@@ -972,7 +970,7 @@ func (s SqlPostStore) AnalyticsPostCount(teamId string, mustHaveFile bool, mustH
}
if mustHaveFile {
- query += " AND Posts.Filenames != '[]'"
+ query += " AND (Posts.FileIds != '[]' OR Posts.Filenames != '[]')"
}
if mustHaveHashtag {
diff --git a/store/sql_post_store_test.go b/store/sql_post_store_test.go
index 6105cbace..d685ea41e 100644
--- a/store/sql_post_store_test.go
+++ b/store/sql_post_store_test.go
@@ -87,45 +87,73 @@ func TestPostStoreUpdate(t *testing.T) {
ro1 := (<-store.Post().Get(o1.Id)).Data.(*model.PostList).Posts[o1.Id]
ro2 := (<-store.Post().Get(o1.Id)).Data.(*model.PostList).Posts[o2.Id]
- ro6 := (<-store.Post().Get(o3.Id)).Data.(*model.PostList).Posts[o3.Id]
+ ro3 := (<-store.Post().Get(o3.Id)).Data.(*model.PostList).Posts[o3.Id]
if ro1.Message != o1.Message {
t.Fatal("Failed to save/get")
}
- msg := o1.Message + "BBBBBBBBBB"
- if result := <-store.Post().Update(ro1, msg, ""); result.Err != nil {
+ o1a := &model.Post{}
+ *o1a = *ro1
+ o1a.Message = ro1.Message + "BBBBBBBBBB"
+ if result := <-store.Post().Update(o1a, ro1); result.Err != nil {
t.Fatal(result.Err)
}
- msg2 := o2.Message + "DDDDDDD"
- if result := <-store.Post().Update(ro2, msg2, ""); result.Err != nil {
- t.Fatal(result.Err)
+ ro1a := (<-store.Post().Get(o1.Id)).Data.(*model.PostList).Posts[o1.Id]
+
+ if ro1a.Message != o1a.Message {
+ t.Fatal("Failed to update/get")
}
- msg3 := o3.Message + "WWWWWWW"
- if result := <-store.Post().Update(ro6, msg3, "#hashtag"); result.Err != nil {
+ o2a := &model.Post{}
+ *o2a = *ro2
+ o2a.Message = ro2.Message + "DDDDDDD"
+ if result := <-store.Post().Update(o2a, ro2); result.Err != nil {
t.Fatal(result.Err)
}
- ro3 := (<-store.Post().Get(o1.Id)).Data.(*model.PostList).Posts[o1.Id]
+ ro2a := (<-store.Post().Get(o1.Id)).Data.(*model.PostList).Posts[o2.Id]
- if ro3.Message != msg {
+ if ro2a.Message != o2a.Message {
t.Fatal("Failed to update/get")
}
- ro4 := (<-store.Post().Get(o1.Id)).Data.(*model.PostList).Posts[o2.Id]
+ o3a := &model.Post{}
+ *o3a = *ro3
+ o3a.Message = ro3.Message + "WWWWWWW"
+ if result := <-store.Post().Update(o3a, ro3); result.Err != nil {
+ t.Fatal(result.Err)
+ }
+
+ ro3a := (<-store.Post().Get(o3.Id)).Data.(*model.PostList).Posts[o3.Id]
- if ro4.Message != msg2 {
+ if ro3a.Message != o3a.Message && ro3a.Hashtags != o3a.Hashtags {
t.Fatal("Failed to update/get")
}
- ro5 := (<-store.Post().Get(o3.Id)).Data.(*model.PostList).Posts[o3.Id]
+ o4 := Must(store.Post().Save(&model.Post{
+ ChannelId: model.NewId(),
+ UserId: model.NewId(),
+ Message: model.NewId(),
+ Filenames: []string{"test"},
+ })).(*model.Post)
- if ro5.Message != msg3 && ro5.Hashtags != "#hashtag" {
- t.Fatal("Failed to update/get")
+ ro4 := (<-store.Post().Get(o4.Id)).Data.(*model.PostList).Posts[o4.Id]
+
+ o4a := &model.Post{}
+ *o4a = *ro4
+ o4a.Filenames = []string{}
+ o4a.FileIds = []string{model.NewId()}
+ if result := <-store.Post().Update(o4a, ro4); result.Err != nil {
+ t.Fatal(result.Err)
}
+ if ro4a := Must(store.Post().Get(o4.Id)).(*model.PostList).Posts[o4.Id]; len(ro4a.Filenames) != 0 {
+ t.Fatal("Failed to clear Filenames")
+ } else if len(ro4a.FileIds) != 1 {
+ t.Fatal("Failed to set FileIds")
+ }
}
func TestPostStoreDelete(t *testing.T) {
diff --git a/store/sql_store.go b/store/sql_store.go
index 4185bb705..a2bc8f1b8 100644
--- a/store/sql_store.go
+++ b/store/sql_store.go
@@ -81,6 +81,7 @@ type SqlStore struct {
recovery PasswordRecoveryStore
emoji EmojiStore
status StatusStore
+ fileInfo FileInfoStore
SchemaVersion string
}
@@ -129,6 +130,7 @@ func NewSqlStore() Store {
sqlStore.recovery = NewSqlPasswordRecoveryStore(sqlStore)
sqlStore.emoji = NewSqlEmojiStore(sqlStore)
sqlStore.status = NewSqlStatusStore(sqlStore)
+ sqlStore.fileInfo = NewSqlFileInfoStore(sqlStore)
err := sqlStore.master.CreateTablesIfNotExists()
if err != nil {
@@ -155,6 +157,7 @@ func NewSqlStore() Store {
sqlStore.recovery.(*SqlPasswordRecoveryStore).CreateIndexesIfNotExists()
sqlStore.emoji.(*SqlEmojiStore).CreateIndexesIfNotExists()
sqlStore.status.(*SqlStatusStore).CreateIndexesIfNotExists()
+ sqlStore.fileInfo.(*SqlFileInfoStore).CreateIndexesIfNotExists()
sqlStore.preference.(*SqlPreferenceStore).DeleteUnusedFeatures()
@@ -643,6 +646,10 @@ func (ss SqlStore) Status() StatusStore {
return ss.status
}
+func (ss SqlStore) FileInfo() FileInfoStore {
+ return ss.fileInfo
+}
+
func (ss SqlStore) DropAllTables() {
ss.master.TruncateTables()
}
diff --git a/store/sql_upgrade.go b/store/sql_upgrade.go
index 0029df8d4..7ee7ea199 100644
--- a/store/sql_upgrade.go
+++ b/store/sql_upgrade.go
@@ -181,7 +181,6 @@ func UpgradeDatabaseToVersion33(sqlStore *SqlStore) {
func UpgradeDatabaseToVersion34(sqlStore *SqlStore) {
if shouldPerformUpgrade(sqlStore, VERSION_3_3_0, VERSION_3_4_0) {
-
sqlStore.CreateColumnIfNotExists("Status", "Manual", "BOOLEAN", "BOOLEAN", "0")
sqlStore.CreateColumnIfNotExists("Status", "ActiveChannel", "varchar(26)", "varchar(26)", "")
@@ -199,6 +198,9 @@ func UpgradeDatabaseToVersion35(sqlStore *SqlStore) {
sqlStore.GetMaster().Exec("UPDATE ChannelMembers SET Roles = 'channel_user' WHERE Roles = ''")
sqlStore.GetMaster().Exec("UPDATE ChannelMembers SET Roles = 'channel_user channel_admin' WHERE Roles = 'admin'")
+ // The rest of the migration from Filenames -> FileIds is done lazily in api.GetFileInfosForPost
+ sqlStore.CreateColumnIfNotExists("Posts", "FileIds", "varchar(150)", "varchar(150)", "[]")
+
// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
// UNCOMMENT WHEN WE DO RELEASE
// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
diff --git a/store/store.go b/store/store.go
index fbe415986..c01b6d8bc 100644
--- a/store/store.go
+++ b/store/store.go
@@ -45,6 +45,7 @@ type Store interface {
PasswordRecovery() PasswordRecoveryStore
Emoji() EmojiStore
Status() StatusStore
+ FileInfo() FileInfoStore
MarkSystemRanUnitTests()
Close()
DropAllTables()
@@ -86,11 +87,13 @@ type ChannelStore interface {
GetMoreChannels(teamId string, userId string) StoreChannel
GetChannelCounts(teamId string, userId string) StoreChannel
GetAll(teamId string) StoreChannel
+ GetForPost(postId string) StoreChannel
SaveMember(member *model.ChannelMember) StoreChannel
UpdateMember(member *model.ChannelMember) StoreChannel
GetMembers(channelId string) StoreChannel
GetMember(channelId string, userId string) StoreChannel
+ GetMemberForPost(postId string, userId string) StoreChannel
GetMemberCount(channelId string) StoreChannel
RemoveMember(channelId string, userId string) StoreChannel
PermanentDeleteMembersByUser(userId string) StoreChannel
@@ -104,7 +107,7 @@ type ChannelStore interface {
type PostStore interface {
Save(post *model.Post) StoreChannel
- Update(post *model.Post, newMessage string, newHashtags string) StoreChannel
+ Update(newPost *model.Post, oldPost *model.Post) StoreChannel
Get(id string) StoreChannel
Delete(postId string, time int64) StoreChannel
PermanentDeleteByUser(userId string) StoreChannel
@@ -277,3 +280,12 @@ type StatusStore interface {
GetTotalActiveUsersCount() StoreChannel
UpdateLastActivityAt(userId string, lastActivityAt int64) StoreChannel
}
+
+type FileInfoStore interface {
+ Save(info *model.FileInfo) StoreChannel
+ Get(id string) StoreChannel
+ GetByPath(path string) StoreChannel
+ GetForPost(postId string) StoreChannel
+ AttachToPost(fileId string, postId string) StoreChannel
+ DeleteForPost(postId string) StoreChannel
+}
diff --git a/utils/utils.go b/utils/utils.go
index 87c81b70f..dd60f6060 100644
--- a/utils/utils.go
+++ b/utils/utils.go
@@ -34,3 +34,17 @@ func FileExistsInConfigFolder(filename string) bool {
}
return false
}
+
+func RemoveDuplicatesFromStringArray(arr []string) []string {
+ result := make([]string, 0, len(arr))
+ seen := make(map[string]bool)
+
+ for _, item := range arr {
+ if !seen[item] {
+ result = append(result, item)
+ seen[item] = true
+ }
+ }
+
+ return result
+}
diff --git a/utils/utils_test.go b/utils/utils_test.go
index 41e995e63..88356dadb 100644
--- a/utils/utils_test.go
+++ b/utils/utils_test.go
@@ -28,3 +28,19 @@ func TestStringArrayIntersection(t *testing.T) {
t.Fatal("should be 1")
}
}
+
+func TestRemoveDuplicatesFromStringArray(t *testing.T) {
+ a := []string{
+ "a",
+ "b",
+ "a",
+ "a",
+ "b",
+ "c",
+ "a",
+ }
+
+ if len(RemoveDuplicatesFromStringArray(a)) != 3 {
+ t.Fatal("should be 3")
+ }
+}
diff --git a/webapp/actions/global_actions.jsx b/webapp/actions/global_actions.jsx
index bc7562d44..81c06fe93 100644
--- a/webapp/actions/global_actions.jsx
+++ b/webapp/actions/global_actions.jsx
@@ -294,11 +294,11 @@ export function showGetPostLinkModal(post) {
});
}
-export function showGetPublicLinkModal(filename) {
+export function showGetPublicLinkModal(fileId) {
AppDispatcher.handleViewAction({
type: ActionTypes.TOGGLE_GET_PUBLIC_LINK_MODAL,
value: true,
- filename
+ fileId
});
}
@@ -388,7 +388,6 @@ export function sendEphemeralPost(message, channelId) {
type: Constants.POST_TYPE_EPHEMERAL,
create_at: timestamp,
update_at: timestamp,
- filenames: [],
props: {}
};
diff --git a/webapp/client/client.jsx b/webapp/client/client.jsx
index 439d41f78..334f8374d 100644
--- a/webapp/client/client.jsx
+++ b/webapp/client/client.jsx
@@ -113,10 +113,14 @@ export default class Client {
return `${this.url}${this.urlVersion}/users`;
}
- getFilesRoute() {
+ getTeamFilesRoute() {
return `${this.url}${this.urlVersion}/teams/${this.getTeamId()}/files`;
}
+ getFileRoute(fileId) {
+ return `${this.url}${this.urlVersion}/files/${fileId}`;
+ }
+
getOAuthRoute() {
return `${this.url}${this.urlVersion}/oauth`;
}
@@ -1520,40 +1524,71 @@ export default class Client {
end(this.handleResponse.bind(this, 'getFlaggedPosts', success, error));
}
+ getFileInfosForPost(channelId, postId, success, error) {
+ request.
+ get(`${this.getChannelNeededRoute(channelId)}/posts/${postId}/get_file_infos`).
+ set(this.defaultHeaders).
+ type('application/json').
+ accept('application/json').
+ end(this.handleResponse.bind(this, 'getFileInfosForPost', success, error));
+ }
+
// Routes for Files
- getFileInfo(filename, success, error) {
+ uploadFile(file, filename, channelId, clientId, success, error) {
+ return request.
+ post(`${this.getTeamFilesRoute()}/upload`).
+ set(this.defaultHeaders).
+ attach('files', file, filename).
+ field('channel_id', channelId).
+ field('client_ids', clientId).
+ accept('application/json').
+ end(this.handleResponse.bind(this, 'uploadFile', success, error));
+ }
+
+ getFile(fileId, success, error) {
request.
- get(`${this.getFilesRoute()}/get_info${filename}`).
+ get(`${this.getFileRoute(fileId)}/get`).
set(this.defaultHeaders).
type('application/json').
accept('application/json').
- end(this.handleResponse.bind(this, 'getFileInfo', success, error));
+ end(this.handleResponse.bind(this, 'getFile', success, error));
}
- getPublicLink(filename, success, error) {
- const data = {
- filename
- };
+ getFileThumbnail(fileId, success, error) {
+ request.
+ get(`${this.getFileRoute(fileId)}/get_thumbnail`).
+ set(this.defaultHeaders).
+ type('application/json').
+ accept('application/json').
+ end(this.handleResponse.bind(this, 'getFileThumbnail', success, error));
+ }
+ getFilePreview(fileId, success, error) {
request.
- post(`${this.getFilesRoute()}/get_public_link`).
+ get(`${this.getFileRoute(fileId)}/get`).
set(this.defaultHeaders).
type('application/json').
accept('application/json').
- send(data).
- end(this.handleResponse.bind(this, 'getPublicLink', success, error));
+ end(this.handleResponse.bind(this, 'getFilePreview', success, error));
}
- uploadFile(file, filename, channelId, clientId, success, error) {
- return request.
- post(`${this.getFilesRoute()}/upload`).
+ getFileInfo(fileId, success, error) {
+ request.
+ get(`${this.getFileRoute(fileId)}/get_info`).
set(this.defaultHeaders).
- attach('files', file, filename).
- field('channel_id', channelId).
- field('client_ids', clientId).
+ type('application/json').
accept('application/json').
- end(this.handleResponse.bind(this, 'uploadFile', success, error));
+ end(this.handleResponse.bind(this, 'getFileInfo', success, error));
+ }
+
+ getPublicLink(fileId, success, error) {
+ request.
+ get(`${this.getFileRoute(fileId)}/get_public_link`).
+ set(this.defaultHeaders).
+ type('application/json').
+ accept('application/json').
+ end(this.handleResponse.bind(this, 'getPublicLink', success, error));
}
// Routes for OAuth
diff --git a/webapp/components/audio_video_preview.jsx b/webapp/components/audio_video_preview.jsx
index dd2e910b3..4956900a9 100644
--- a/webapp/components/audio_video_preview.jsx
+++ b/webapp/components/audio_video_preview.jsx
@@ -76,10 +76,8 @@ export default class AudioVideoPreview extends React.Component {
if (!this.state.canPlay) {
return (
<FileInfoPreview
- filename={this.props.filename}
- fileUrl={this.props.fileUrl}
fileInfo={this.props.fileInfo}
- formatMessage={this.props.formatMessage}
+ fileUrl={this.props.fileUrl}
/>
);
}
@@ -94,7 +92,7 @@ export default class AudioVideoPreview extends React.Component {
// add a key to the video to prevent React from using an old video source while a new one is loading
return (
<video
- key={this.props.filename}
+ key={this.props.fileInfo.id}
ref='video'
style={{maxHeight: this.props.maxHeight}}
data-setup='{}'
@@ -112,9 +110,7 @@ export default class AudioVideoPreview extends React.Component {
}
AudioVideoPreview.propTypes = {
- filename: React.PropTypes.string.isRequired,
- fileUrl: React.PropTypes.string.isRequired,
fileInfo: React.PropTypes.object.isRequired,
- maxHeight: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.number]).isRequired,
- formatMessage: React.PropTypes.func.isRequired
+ fileUrl: React.PropTypes.string.isRequired,
+ maxHeight: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.number]).isRequired
};
diff --git a/webapp/components/code_preview.jsx b/webapp/components/code_preview.jsx
index 6625f45f4..852b26e25 100644
--- a/webapp/components/code_preview.jsx
+++ b/webapp/components/code_preview.jsx
@@ -38,7 +38,7 @@ export default class CodePreview extends React.Component {
}
updateStateFromProps(props) {
- var usedLanguage = SyntaxHighlighting.getLanguageFromFilename(props.filename);
+ const usedLanguage = SyntaxHighlighting.getLanguageFromFileExtension(props.fileInfo.extension);
if (!usedLanguage || props.fileInfo.size > Constants.CODE_PREVIEW_MAX_FILE_SIZE) {
this.setState({code: '', lang: '', loading: false, success: false});
@@ -64,8 +64,8 @@ export default class CodePreview extends React.Component {
this.setState({loading: false, success: false});
}
- static support(filename) {
- return Boolean(SyntaxHighlighting.getLanguageFromFilename(filename));
+ static supports(fileInfo) {
+ return Boolean(SyntaxHighlighting.getLanguageFromFileExtension(fileInfo.extension));
}
render() {
@@ -83,10 +83,8 @@ export default class CodePreview extends React.Component {
if (!this.state.success) {
return (
<FileInfoPreview
- filename={this.props.filename}
- fileUrl={this.props.fileUrl}
fileInfo={this.props.fileInfo}
- formatMessage={this.props.formatMessage}
+ fileUrl={this.props.fileUrl}
/>
);
}
@@ -106,12 +104,10 @@ export default class CodePreview extends React.Component {
const highlighted = SyntaxHighlighting.highlight(this.state.lang, this.state.code);
- const fileName = this.props.filename.substring(this.props.filename.lastIndexOf('/') + 1, this.props.filename.length);
-
return (
<div className='post-code'>
<span className='post-code__language'>
- {`${fileName} - ${language}`}
+ {`${this.props.fileInfo.name} - ${language}`}
</span>
<code className='hljs'>
<table>
@@ -129,8 +125,6 @@ export default class CodePreview extends React.Component {
}
CodePreview.propTypes = {
- filename: React.PropTypes.string.isRequired,
- fileUrl: React.PropTypes.string.isRequired,
fileInfo: React.PropTypes.object.isRequired,
- formatMessage: React.PropTypes.func.isRequired
+ fileUrl: React.PropTypes.string.isRequired
};
diff --git a/webapp/components/create_comment.jsx b/webapp/components/create_comment.jsx
index 2f0698510..133c2e6d2 100644
--- a/webapp/components/create_comment.jsx
+++ b/webapp/components/create_comment.jsx
@@ -55,7 +55,7 @@ export default class CreateComment extends React.Component {
this.state = {
messageText: draft.message,
uploadsInProgress: draft.uploadsInProgress,
- previews: draft.previews,
+ fileInfos: draft.fileInfos,
submitting: false,
ctrlSend: PreferenceStore.getBool(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter'),
showPostDeletedModal: false
@@ -99,10 +99,10 @@ export default class CreateComment extends React.Component {
}
const post = {};
- post.filenames = [];
+ post.file_ids = [];
post.message = this.state.messageText;
- if (post.message.trim().length === 0 && this.state.previews.length === 0) {
+ if (post.message.trim().length === 0 && this.state.fileInfos.length === 0) {
return;
}
@@ -126,7 +126,7 @@ export default class CreateComment extends React.Component {
post.channel_id = this.props.channelId;
post.root_id = this.props.rootId;
post.parent_id = this.props.rootId;
- post.filenames = this.state.previews;
+ post.file_ids = this.state.fileInfos.map((info) => info.id);
const time = Utils.getTimestamp();
post.pending_post_id = `${userId}:${time}`;
post.user_id = userId;
@@ -163,7 +163,7 @@ export default class CreateComment extends React.Component {
messageText: '',
submitting: false,
postError: null,
- previews: [],
+ fileInfos: [],
serverError: null
});
}
@@ -245,7 +245,7 @@ export default class CreateComment extends React.Component {
this.focusTextbox();
}
- handleFileUploadComplete(filenames, clientIds) {
+ handleFileUploadComplete(fileInfos, clientIds) {
const draft = PostStore.getCommentDraft(this.props.rootId);
// remove each finished file from uploads
@@ -257,10 +257,10 @@ export default class CreateComment extends React.Component {
}
}
- draft.previews = draft.previews.concat(filenames);
+ draft.fileInfos = draft.fileInfos.concat(fileInfos);
PostStore.storeCommentDraft(this.props.rootId, draft);
- this.setState({uploadsInProgress: draft.uploadsInProgress, previews: draft.previews});
+ this.setState({uploadsInProgress: draft.uploadsInProgress, fileInfos: draft.fileInfos});
}
handleUploadError(err, clientId) {
@@ -281,11 +281,11 @@ export default class CreateComment extends React.Component {
}
removePreview(id) {
- const previews = this.state.previews;
+ const fileInfos = this.state.fileInfos;
const uploadsInProgress = this.state.uploadsInProgress;
- // id can either be the path of an uploaded file or the client id of an in progress upload
- let index = previews.indexOf(id);
+ // id can either be the id of an uploaded file or the client id of an in progress upload
+ let index = fileInfos.findIndex((info) => info.id === id);
if (index === -1) {
index = uploadsInProgress.indexOf(id);
@@ -294,26 +294,26 @@ export default class CreateComment extends React.Component {
this.refs.fileUpload.getWrappedInstance().cancelUpload(id);
}
} else {
- previews.splice(index, 1);
+ fileInfos.splice(index, 1);
}
const draft = PostStore.getCommentDraft(this.props.rootId);
- draft.previews = previews;
+ draft.fileInfos = fileInfos;
draft.uploadsInProgress = uploadsInProgress;
PostStore.storeCommentDraft(this.props.rootId, draft);
- this.setState({previews, uploadsInProgress});
+ this.setState({fileInfos, uploadsInProgress});
}
componentWillReceiveProps(newProps) {
if (newProps.rootId !== this.props.rootId) {
const draft = PostStore.getCommentDraft(newProps.rootId);
- this.setState({messageText: draft.message, uploadsInProgress: draft.uploadsInProgress, previews: draft.previews});
+ this.setState({messageText: draft.message, uploadsInProgress: draft.uploadsInProgress, fileInfos: draft.fileInfos});
}
}
getFileCount() {
- return this.state.previews.length + this.state.uploadsInProgress.length;
+ return this.state.fileInfos.length + this.state.uploadsInProgress.length;
}
focusTextbox() {
@@ -350,10 +350,10 @@ export default class CreateComment extends React.Component {
}
let preview = null;
- if (this.state.previews.length > 0 || this.state.uploadsInProgress.length > 0) {
+ if (this.state.fileInfos.length > 0 || this.state.uploadsInProgress.length > 0) {
preview = (
<FilePreview
- files={this.state.previews}
+ fileInfos={this.state.fileInfos}
onRemove={this.removePreview}
uploadsInProgress={this.state.uploadsInProgress}
/>
diff --git a/webapp/components/create_post.jsx b/webapp/components/create_post.jsx
index bfacd0644..d3417e419 100644
--- a/webapp/components/create_post.jsx
+++ b/webapp/components/create_post.jsx
@@ -67,7 +67,7 @@ export default class CreatePost extends React.Component {
channelId: ChannelStore.getCurrentId(),
messageText: draft.messageText,
uploadsInProgress: draft.uploadsInProgress,
- previews: draft.previews,
+ fileInfos: draft.fileInfos,
submitting: false,
initialText: draft.messageText,
ctrlSend: PreferenceStore.getBool(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter'),
@@ -79,14 +79,14 @@ export default class CreatePost extends React.Component {
getCurrentDraft() {
const draft = PostStore.getCurrentDraft();
- const safeDraft = {previews: [], messageText: '', uploadsInProgress: []};
+ const safeDraft = {fileInfos: [], messageText: '', uploadsInProgress: []};
if (draft) {
if (draft.message) {
safeDraft.messageText = draft.message;
}
- if (draft.previews) {
- safeDraft.previews = draft.previews;
+ if (draft.fileInfos) {
+ safeDraft.fileInfos = draft.fileInfos;
}
if (draft.uploadsInProgress) {
safeDraft.uploadsInProgress = draft.uploadsInProgress;
@@ -104,10 +104,10 @@ export default class CreatePost extends React.Component {
}
const post = {};
- post.filenames = [];
+ post.file_ids = [];
post.message = this.state.messageText;
- if (post.message.trim().length === 0 && this.state.previews.length === 0) {
+ if (post.message.trim().length === 0 && this.state.fileInfos.length === 0) {
return;
}
@@ -122,7 +122,7 @@ export default class CreatePost extends React.Component {
if (post.message.indexOf('/') === 0) {
PostStore.storeDraft(this.state.channelId, null);
- this.setState({messageText: '', postError: null, previews: []});
+ this.setState({messageText: '', postError: null, fileInfos: []});
ChannelActions.executeCommand(
this.state.channelId,
@@ -153,7 +153,7 @@ export default class CreatePost extends React.Component {
sendMessage(post) {
post.channel_id = this.state.channelId;
- post.filenames = this.state.previews;
+ post.file_ids = this.state.fileInfos.map((info) => info.id);
const time = Utils.getTimestamp();
const userId = UserStore.getCurrentId();
@@ -163,7 +163,7 @@ export default class CreatePost extends React.Component {
post.parent_id = this.state.parentId;
GlobalActions.emitUserPostedEvent(post);
- this.setState({messageText: '', submitting: false, postError: null, previews: [], serverError: null});
+ this.setState({messageText: '', submitting: false, postError: null, fileInfos: [], serverError: null});
Client.createPost(post,
(data) => {
@@ -236,7 +236,7 @@ export default class CreatePost extends React.Component {
this.focusTextbox();
}
- handleFileUploadComplete(filenames, clientIds, channelId) {
+ handleFileUploadComplete(fileInfos, clientIds, channelId) {
const draft = PostStore.getDraft(channelId);
// remove each finished file from uploads
@@ -248,11 +248,11 @@ export default class CreatePost extends React.Component {
}
}
- draft.previews = draft.previews.concat(filenames);
+ draft.fileInfos = draft.fileInfos.concat(fileInfos);
PostStore.storeDraft(channelId, draft);
if (channelId === this.state.channelId) {
- this.setState({uploadsInProgress: draft.uploadsInProgress, previews: draft.previews});
+ this.setState({uploadsInProgress: draft.uploadsInProgress, fileInfos: draft.fileInfos});
}
}
@@ -282,11 +282,11 @@ export default class CreatePost extends React.Component {
}
removePreview(id) {
- const previews = Object.assign([], this.state.previews);
+ const fileInfos = Object.assign([], this.state.fileInfos);
const uploadsInProgress = this.state.uploadsInProgress;
- // id can either be the path of an uploaded file or the client id of an in progress upload
- let index = previews.indexOf(id);
+ // id can either be the id of an uploaded file or the client id of an in progress upload
+ let index = fileInfos.findIndex((info) => info.id === id);
if (index === -1) {
index = uploadsInProgress.indexOf(id);
@@ -295,15 +295,15 @@ export default class CreatePost extends React.Component {
this.refs.fileUpload.getWrappedInstance().cancelUpload(id);
}
} else {
- previews.splice(index, 1);
+ fileInfos.splice(index, 1);
}
const draft = PostStore.getCurrentDraft();
- draft.previews = previews;
+ draft.fileInfos = fileInfos;
draft.uploadsInProgress = uploadsInProgress;
PostStore.storeCurrentDraft(draft);
- this.setState({previews, uploadsInProgress});
+ this.setState({fileInfos, uploadsInProgress});
}
componentWillMount() {
@@ -336,6 +336,7 @@ export default class CreatePost extends React.Component {
PreferenceStore.removeChangeListener(this.onPreferenceChange);
document.removeEventListener('keydown', this.showShortcuts);
}
+
showShortcuts(e) {
if ((e.ctrlKey || e.metaKey) && e.keyCode === Constants.KeyCodes.FORWARD_SLASH) {
e.preventDefault();
@@ -359,7 +360,7 @@ export default class CreatePost extends React.Component {
if (this.state.channelId !== channelId) {
const draft = this.getCurrentDraft();
- this.setState({channelId, messageText: draft.messageText, initialText: draft.messageText, submitting: false, serverError: null, postError: null, previews: draft.previews, uploadsInProgress: draft.uploadsInProgress});
+ this.setState({channelId, messageText: draft.messageText, initialText: draft.messageText, submitting: false, serverError: null, postError: null, fileInfos: draft.fileInfos, uploadsInProgress: draft.uploadsInProgress});
}
}
@@ -374,11 +375,11 @@ export default class CreatePost extends React.Component {
getFileCount(channelId) {
if (channelId === this.state.channelId) {
- return this.state.previews.length + this.state.uploadsInProgress.length;
+ return this.state.fileInfos.length + this.state.uploadsInProgress.length;
}
const draft = PostStore.getDraft(channelId);
- return draft.previews.length + draft.uploadsInProgress.length;
+ return draft.fileInfos.length + draft.uploadsInProgress.length;
}
handleKeyDown(e) {
@@ -474,10 +475,10 @@ export default class CreatePost extends React.Component {
}
let preview = null;
- if (this.state.previews.length > 0 || this.state.uploadsInProgress.length > 0) {
+ if (this.state.fileInfos.length > 0 || this.state.uploadsInProgress.length > 0) {
preview = (
<FilePreview
- files={this.state.previews}
+ fileInfos={this.state.fileInfos}
onRemove={this.removePreview}
uploadsInProgress={this.state.uploadsInProgress}
/>
diff --git a/webapp/components/file_attachment.jsx b/webapp/components/file_attachment.jsx
index cba9d8288..23d8d2446 100644
--- a/webapp/components/file_attachment.jsx
+++ b/webapp/components/file_attachment.jsx
@@ -1,204 +1,111 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
-import $ from 'jquery';
-import ReactDOM from 'react-dom';
-import * as utils from 'utils/utils.jsx';
-import Client from 'client/web_client.jsx';
import Constants from 'utils/constants.jsx';
+import FileStore from 'stores/file_store.jsx';
+import * as Utils from 'utils/utils.jsx';
-import {intlShape, injectIntl, defineMessages} from 'react-intl';
import {Tooltip, OverlayTrigger} from 'react-bootstrap';
-const holders = defineMessages({
- download: {
- id: 'file_attachment.download',
- defaultMessage: 'Download'
- }
-});
-
import React from 'react';
-class FileAttachment extends React.Component {
+export default class FileAttachment extends React.Component {
constructor(props) {
super(props);
this.loadFiles = this.loadFiles.bind(this);
- this.addBackgroundImage = this.addBackgroundImage.bind(this);
this.onAttachmentClick = this.onAttachmentClick.bind(this);
- this.canSetState = false;
- this.state = {fileSize: -1};
+ this.state = {
+ loaded: Utils.getFileType(props.fileInfo.extension) !== 'image'
+ };
}
+
componentDidMount() {
this.loadFiles();
}
- componentDidUpdate(prevProps) {
- if (this.props.filename !== prevProps.filename) {
- this.loadFiles();
- }
- }
- loadFiles() {
- this.canSetState = true;
-
- var filename = this.props.filename;
-
- if (filename) {
- var fileInfo = this.getFileInfoFromName(filename);
- var type = utils.getFileType(fileInfo.ext);
-
- if (type === 'image') {
- var self = this; // Need this reference since we use the given "this"
- $('<img/>').attr('src', fileInfo.path + '_thumb.jpg').on('load', (function loadWrapper(path, name) {
- return function loader() {
- $(this).remove();
- if (name in self.refs) {
- var imgDiv = ReactDOM.findDOMNode(self.refs[name]);
-
- $(imgDiv).removeClass('post-image__load');
- $(imgDiv).addClass('post-image');
-
- var width = this.width || $(this).width();
- var height = this.height || $(this).height();
-
- if (width < Constants.THUMBNAIL_WIDTH &&
- height < Constants.THUMBNAIL_HEIGHT) {
- $(imgDiv).addClass('small');
- } else {
- $(imgDiv).addClass('normal');
- }
- self.addBackgroundImage(name, path);
- }
- };
- }(fileInfo.path, filename)));
- }
+ componentWillReceiveProps(nextProps) {
+ if (nextProps.fileInfo.id !== this.props.fileInfo.id) {
+ this.setState({
+ loaded: Utils.getFileType(nextProps.fileInfo.extension) !== 'image'
+ });
}
}
- componentWillUnmount() {
- // keep track of when this component is mounted so that we can asynchronously change state without worrying about whether or not we're mounted
- this.canSetState = false;
- }
- shouldComponentUpdate(nextProps, nextState) {
- if (!utils.areObjectsEqual(nextProps, this.props)) {
- return true;
- }
-
- // the only time this object should update is when it receives an updated file size which we can usually handle without re-rendering
- if (nextState.fileSize !== this.state.fileSize) {
- if (this.refs.fileSize) {
- // update the UI element to display the file size without re-rendering the whole component
- ReactDOM.findDOMNode(this.refs.fileSize).innerHTML = utils.fileSizeToString(nextState.fileSize);
- return false;
- }
-
- // we can't find the element that should hold the file size so we must not have rendered yet
- return true;
+ componentDidUpdate(prevProps) {
+ if (!this.state.loaded && this.props.fileInfo.id !== prevProps.fileInfo.id) {
+ this.loadFiles();
}
-
- return true;
- }
- getFileInfoFromName(name) {
- var fileInfo = utils.splitFileLocation(name);
-
- fileInfo.path = Client.getFilesRoute() + '/get' + fileInfo.path;
-
- return fileInfo;
}
- addBackgroundImage(name, path) {
- var fileUrl = path;
- if (name in this.refs) {
- if (!path) {
- fileUrl = this.getFileInfoFromName(name).path;
- }
+ loadFiles() {
+ const fileInfo = this.props.fileInfo;
+ const fileType = Utils.getFileType(fileInfo.extension);
- var imgDiv = ReactDOM.findDOMNode(this.refs[name]);
- var re1 = new RegExp(' ', 'g');
- var re2 = new RegExp('\\(', 'g');
- var re3 = new RegExp('\\)', 'g');
- var url = fileUrl.replace(re1, '%20').replace(re2, '%28').replace(re3, '%29');
+ if (fileType === 'image') {
+ const thumbnailUrl = FileStore.getFileThumbnailUrl(fileInfo.id);
- $(imgDiv).css('background-image', 'url(' + url + '_thumb.jpg)');
- }
- }
- removeBackgroundImage(name) {
- if (name in this.refs) {
- $(ReactDOM.findDOMNode(this.refs[name])).css('background-image', 'initial');
+ const img = new Image();
+ img.onload = () => {
+ this.setState({loaded: true});
+ };
+ img.load(thumbnailUrl);
}
}
+
onAttachmentClick(e) {
e.preventDefault();
this.props.handleImageClick(this.props.index);
}
+
render() {
- var filename = this.props.filename;
+ const fileInfo = this.props.fileInfo;
+ const fileName = fileInfo.name;
+ const fileUrl = FileStore.getFileUrl(fileInfo.id);
- var fileInfo = utils.splitFileLocation(filename);
- var fileUrl = utils.getFileUrl(filename);
- var type = utils.getFileType(fileInfo.ext);
+ let thumbnail;
+ if (this.state.loaded) {
+ const type = Utils.getFileType(fileInfo.extension);
- var thumbnail;
- if (type === 'image') {
- thumbnail = (
- <div
- ref={filename}
- className='post-image__load'
- />
- );
- } else {
- thumbnail = <div className={'file-icon ' + utils.getIconClassName(type)}/>;
- }
+ if (type === 'image') {
+ let className = 'post-image';
- var fileSizeString = '';
- if (this.state.fileSize < 0) {
- Client.getFileInfo(
- filename,
- (data) => {
- if (this.canSetState) {
- this.setState({fileSize: parseInt(data.size, 10)});
- }
- },
- () => {
- // Do nothing
+ if (fileInfo.width < Constants.THUMBNAIL_WIDTH && fileInfo.height < Constants.THUMBNAIL_HEIGHT) {
+ className += ' small';
+ } else {
+ className += ' normal';
}
- );
+
+ thumbnail = (
+ <div
+ className={className}
+ style={{
+ backgroundImage: `url(${FileStore.getFileThumbnailUrl(fileInfo.id)})`
+ }}
+ />
+ );
+ } else {
+ thumbnail = <div className={'file-icon ' + Utils.getIconClassName(type)}/>;
+ }
} else {
- fileSizeString = utils.fileSizeToString(this.state.fileSize);
+ thumbnail = <div className='post-image__load'/>;
}
- var filenameString = decodeURIComponent(utils.getFileName(filename));
- var trimmedFilename;
- if (filenameString.length > 35) {
- trimmedFilename = filenameString.substring(0, Math.min(35, filenameString.length)) + '...';
+ let trimmedFilename;
+ if (fileName.length > 35) {
+ trimmedFilename = fileName.substring(0, Math.min(35, fileName.length)) + '...';
} else {
- trimmedFilename = filenameString;
+ trimmedFilename = fileName;
}
- var filenameOverlay = (
- <OverlayTrigger
- delayShow={1000}
- placement='top'
- overlay={<Tooltip id='file-name__tooltip'>{this.props.intl.formatMessage(holders.download) + ' "' + filenameString + '"'}</Tooltip>}
- >
- <a
- href={fileUrl}
- download={filenameString}
- className='post-image__name'
- target='_blank'
- rel='noopener noreferrer'
- >
- {trimmedFilename}
- </a>
- </OverlayTrigger>
- );
+ let filenameOverlay;
if (this.props.compactDisplay) {
filenameOverlay = (
<OverlayTrigger
delayShow={1000}
placement='top'
- overlay={<Tooltip id='file-name__tooltip'>{filenameString}</Tooltip>}
+ overlay={<Tooltip id='file-name__tooltip'>{fileName}</Tooltip>}
>
<a
href='#'
@@ -214,13 +121,28 @@ class FileAttachment extends React.Component {
</a>
</OverlayTrigger>
);
+ } else {
+ filenameOverlay = (
+ <OverlayTrigger
+ delayShow={1000}
+ placement='top'
+ overlay={<Tooltip id='file-name__tooltip'>{Utils.localizeMessage('file_attachment.download', 'Download') + ' "' + fileName + '"'}</Tooltip>}
+ >
+ <a
+ href={fileUrl}
+ download={fileName}
+ className='post-image__name'
+ target='_blank'
+ rel='noopener noreferrer'
+ >
+ {trimmedFilename}
+ </a>
+ </OverlayTrigger>
+ );
}
return (
- <div
- className='post-image__column'
- key={filename}
- >
+ <div className='post-image__column'>
<a
className='post-image__thumbnail'
href='#'
@@ -233,17 +155,15 @@ class FileAttachment extends React.Component {
<div>
<a
href={fileUrl}
- download={filenameString}
+ download={fileName}
className='post-image__download'
target='_blank'
rel='noopener noreferrer'
>
- <span
- className='fa fa-download'
- />
+ <span className='fa fa-download'/>
</a>
- <span className='post-image__type'>{fileInfo.ext.toUpperCase()}</span>
- <span className='post-image__size'>{fileSizeString}</span>
+ <span className='post-image__type'>{fileInfo.extension.toUpperCase()}</span>
+ <span className='post-image__size'>{Utils.fileSizeToString(fileInfo.size)}</span>
</div>
</div>
</div>
@@ -252,10 +172,7 @@ class FileAttachment extends React.Component {
}
FileAttachment.propTypes = {
- intl: intlShape.isRequired,
-
- // a list of file pathes displayed by the parent FileAttachmentList
- filename: React.PropTypes.string.isRequired,
+ fileInfo: React.PropTypes.object.isRequired,
// the index of this attachment preview in the parent FileAttachmentList
index: React.PropTypes.number.isRequired,
@@ -264,6 +181,4 @@ FileAttachment.propTypes = {
handleImageClick: React.PropTypes.func,
compactDisplay: React.PropTypes.bool
-};
-
-export default injectIntl(FileAttachment);
+}; \ No newline at end of file
diff --git a/webapp/components/file_attachment_list.jsx b/webapp/components/file_attachment_list.jsx
index e4b841769..3df4684be 100644
--- a/webapp/components/file_attachment_list.jsx
+++ b/webapp/components/file_attachment_list.jsx
@@ -13,25 +13,34 @@ export default class FileAttachmentList extends React.Component {
this.handleImageClick = this.handleImageClick.bind(this);
- this.state = {showPreviewModal: false, startImgId: 0};
+ this.state = {showPreviewModal: false, startImgIndex: 0};
}
+
handleImageClick(indexClicked) {
- this.setState({showPreviewModal: true, startImgId: indexClicked});
+ this.setState({showPreviewModal: true, startImgIndex: indexClicked});
}
+
render() {
- var filenames = this.props.filenames;
+ const postFiles = [];
+ if (this.props.fileInfos && this.props.fileInfos.length > 0) {
+ for (let i = 0; i < Math.min(this.props.fileInfos.length, Constants.MAX_DISPLAY_FILES); i++) {
+ const fileInfo = this.props.fileInfos[i];
- var postFiles = [];
- for (var i = 0; i < filenames.length && i < Constants.MAX_DISPLAY_FILES; i++) {
- postFiles.push(
- <FileAttachment
- key={'file_attachment_' + i}
- filename={filenames[i]}
- index={i}
- handleImageClick={this.handleImageClick}
- compactDisplay={this.props.compactDisplay}
- />
- );
+ postFiles.push(
+ <FileAttachment
+ key={fileInfo.id}
+ fileInfo={this.props.fileInfos[i]}
+ index={i}
+ handleImageClick={this.handleImageClick}
+ compactDisplay={this.props.compactDisplay}
+ />
+ );
+ }
+ } else if (this.props.fileCount > 0) {
+ for (let i = 0; i < Math.min(this.props.fileCount, Constants.MAX_DISPLAY_FILES); i++) {
+ // Add a placeholder to avoid pop-in once we get the file infos for this post
+ postFiles.push(<div className='post-image__column post-image__column--placeholder'/>);
+ }
}
return (
@@ -42,10 +51,8 @@ export default class FileAttachmentList extends React.Component {
<ViewImageModal
show={this.state.showPreviewModal}
onModalDismissed={() => this.setState({showPreviewModal: false})}
- channelId={this.props.channelId}
- userId={this.props.userId}
- startId={this.state.startImgId}
- filenames={filenames}
+ startId={this.state.startImgIndex}
+ fileInfos={this.props.fileInfos}
/>
</div>
);
@@ -53,15 +60,7 @@ export default class FileAttachmentList extends React.Component {
}
FileAttachmentList.propTypes = {
-
- // a list of file pathes displayed by this
- filenames: React.PropTypes.arrayOf(React.PropTypes.string).isRequired,
-
- // the channel that this is part of
- channelId: React.PropTypes.string,
-
- // the user that owns the post that this is attached to
- userId: React.PropTypes.string,
-
+ fileCount: React.PropTypes.number.isRequired,
+ fileInfos: React.PropTypes.arrayOf(React.PropTypes.object).isRequired,
compactDisplay: React.PropTypes.bool
};
diff --git a/webapp/components/file_attachment_list_container.jsx b/webapp/components/file_attachment_list_container.jsx
new file mode 100644
index 000000000..f9ad3814c
--- /dev/null
+++ b/webapp/components/file_attachment_list_container.jsx
@@ -0,0 +1,90 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import React from 'react';
+
+import * as AsyncClient from 'utils/async_client.jsx';
+import FileStore from 'stores/file_store.jsx';
+
+import FileAttachmentList from './file_attachment_list.jsx';
+
+export default class FileAttachmentListContainer extends React.Component {
+ static propTypes = {
+ post: React.PropTypes.object.isRequired,
+ compactDisplay: React.PropTypes.bool.isRequired
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.handleFileChange = this.handleFileChange.bind(this);
+
+ this.state = {
+ fileInfos: FileStore.getInfosForPost(props.post.id)
+ };
+ }
+
+ componentDidMount() {
+ FileStore.addChangeListener(this.handleFileChange);
+
+ if (this.props.post.id && !FileStore.hasInfosForPost(this.props.post.id)) {
+ AsyncClient.getFileInfosForPost(this.props.post.channel_id, this.props.post.id);
+ }
+ }
+
+ componentWillReceiveProps(nextProps) {
+ if (nextProps.post.id !== this.props.post.id) {
+ this.setState({
+ fileInfos: FileStore.getInfosForPost(nextProps.post.id)
+ });
+
+ if (nextProps.post.id && !FileStore.hasInfosForPost(nextProps.post.id)) {
+ AsyncClient.getFileInfosForPost(nextProps.post.channel_id, nextProps.post.id);
+ }
+ }
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ if (this.props.post.id !== nextProps.post.id) {
+ return true;
+ }
+
+ if (this.props.compactDisplay !== nextProps.compactDisplay) {
+ return true;
+ }
+
+ // fileInfos are treated as immutable by the FileStore
+ if (nextState.fileInfos !== this.state.fileInfos) {
+ return true;
+ }
+
+ return false;
+ }
+
+ handleFileChange() {
+ this.setState({
+ fileInfos: FileStore.getInfosForPost(this.props.post.id)
+ });
+ }
+
+ componentWillUnmount() {
+ FileStore.removeChangeListener(this.handleFileChange);
+ }
+
+ render() {
+ let fileCount = 0;
+ if (this.props.post.file_ids) {
+ fileCount = this.props.post.file_ids.length;
+ } else if (this.props.post.filenames) {
+ fileCount = this.props.post.filenames.length;
+ }
+
+ return (
+ <FileAttachmentList
+ fileCount={fileCount}
+ fileInfos={this.state.fileInfos}
+ compactDisplay={this.props.compactDisplay}
+ />
+ );
+ }
+}
diff --git a/webapp/components/file_info_preview.jsx b/webapp/components/file_info_preview.jsx
index b3d16b6a6..51825ce5b 100644
--- a/webapp/components/file_info_preview.jsx
+++ b/webapp/components/file_info_preview.jsx
@@ -1,59 +1,59 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
+import React from 'react';
+
import * as Utils from 'utils/utils.jsx';
-import {defineMessages} from 'react-intl';
-import React from 'react';
-import {Link} from 'react-router/es6';
-
-const holders = defineMessages({
- type: {
- id: 'file_info_preview.type',
- defaultMessage: 'File type '
- },
- size: {
- id: 'file_info_preview.size',
- defaultMessage: 'Size '
- }
-});
+export default class FileInfoPreview extends React.Component {
+ shouldComponentUpdate(nextProps) {
+ if (nextProps.fileUrl !== this.props.fileUrl) {
+ return true;
+ }
-export default function FileInfoPreview({filename, fileUrl, fileInfo, formatMessage}) {
- // non-image files include a section providing details about the file
- const infoParts = [];
+ if (!Utils.areObjectsEqual(nextProps.fileInfo, this.props.fileInfo)) {
+ return true;
+ }
- if (fileInfo.extension !== '') {
- infoParts.push(formatMessage(holders.type) + fileInfo.extension.toUpperCase());
+ return false;
}
- infoParts.push(formatMessage(holders.size) + Utils.fileSizeToString(fileInfo.size));
-
- const infoString = infoParts.join(', ');
-
- const name = decodeURIComponent(Utils.getFileName(filename));
-
- return (
- <div className='file-details__container'>
- <Link
- className={'file-details__preview'}
- to={fileUrl}
- target='_blank'
- rel='noopener noreferrer'
- >
- <span className='file-details__preview-helper'/>
- <img src={Utils.getPreviewImagePath(filename)}/>
- </Link>
- <div className='file-details'>
- <div className='file-details__name'>{name}</div>
- <div className='file-details__info'>{infoString}</div>
+ render() {
+ const fileInfo = this.props.fileInfo;
+ const fileUrl = this.props.fileUrl;
+
+ // non-image files include a section providing details about the file
+ const infoParts = [];
+
+ if (fileInfo.extension !== '') {
+ infoParts.push(Utils.localizeMessage('file_info_preview.type', 'File type ') + fileInfo.extension.toUpperCase());
+ }
+
+ infoParts.push(Utils.localizeMessage('file_info_preview.size', 'Size ') + Utils.fileSizeToString(fileInfo.size));
+
+ const infoString = infoParts.join(', ');
+
+ return (
+ <div className='file-details__container'>
+ <a
+ className={'file-details__preview'}
+ to={fileUrl}
+ target='_blank'
+ rel='noopener noreferrer'
+ >
+ <span className='file-details__preview-helper'/>
+ <img src={Utils.getFileIconPath(fileInfo)}/>
+ </a>
+ <div className='file-details'>
+ <div className='file-details__name'>{fileInfo.name}</div>
+ <div className='file-details__info'>{infoString}</div>
+ </div>
</div>
- </div>
- );
+ );
+ }
}
FileInfoPreview.propTypes = {
- filename: React.PropTypes.string.isRequired,
- fileUrl: React.PropTypes.string.isRequired,
fileInfo: React.PropTypes.object.isRequired,
- formatMessage: React.PropTypes.func.isRequired
+ fileUrl: React.PropTypes.string.isRequired
};
diff --git a/webapp/components/file_preview.jsx b/webapp/components/file_preview.jsx
index 46ce43a6f..53cec7f7b 100644
--- a/webapp/components/file_preview.jsx
+++ b/webapp/components/file_preview.jsx
@@ -1,6 +1,7 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
+import FileStore from 'stores/file_store.jsx';
import ReactDOM from 'react-dom';
import * as Utils from 'utils/utils.jsx';
@@ -21,63 +22,43 @@ export default class FilePreview extends React.Component {
}
}
- handleRemove(e) {
- var previewDiv = e.target.parentNode.parentNode;
-
- if (previewDiv.hasAttribute('data-filename')) {
- this.props.onRemove(previewDiv.getAttribute('data-filename'));
- } else if (previewDiv.hasAttribute('data-client-id')) {
- this.props.onRemove(previewDiv.getAttribute('data-client-id'));
- }
+ handleRemove(id) {
+ this.props.onRemove(id);
}
render() {
var previews = [];
- this.props.files.forEach((fullFilename) => {
- var filename = fullFilename;
- var originalFilename = filename;
- var filenameSplit = filename.split('.');
- var ext = filenameSplit[filenameSplit.length - 1];
- var type = Utils.getFileType(ext);
-
- filename = Utils.getFileUrl(filename);
+ this.props.fileInfos.forEach((info) => {
+ const type = Utils.getFileType(info.extension);
+ let className = 'file-preview';
+ let previewImage;
if (type === 'image') {
- previews.push(
- <div
- key={filename}
- className='file-preview'
- data-filename={originalFilename}
- >
- <img
- className='file-preview__image'
- src={filename}
- />
- <a
- className='file-preview__remove'
- onClick={this.handleRemove}
- >
- <i className='fa fa-remove'/>
- </a>
- </div>
+ previewImage = (
+ <img
+ className='file-preview__image'
+ src={FileStore.getFileUrl(info.id)}
+ />
);
} else {
- previews.push(
- <div
- key={filename}
- className='file-preview custom-file'
- data-filename={originalFilename}
- >
- <div className={'file-icon ' + Utils.getIconClassName(type)}/>
- <a
- className='file-preview__remove'
- onClick={this.handleRemove}
- >
- <i className='fa fa-remove'/>
- </a>
- </div>
- );
+ className += ' custom-file';
+ previewImage = <div className={'file-icon ' + Utils.getIconClassName(type)}/>;
}
+
+ previews.push(
+ <div
+ key={info.id}
+ className={className}
+ >
+ {previewImage}
+ <a
+ className='file-preview__remove'
+ onClick={this.handleRemove.bind(this, info.id)}
+ >
+ <i className='fa fa-remove'/>
+ </a>
+ </div>
+ );
});
this.props.uploadsInProgress.forEach((clientId) => {
@@ -94,7 +75,7 @@ export default class FilePreview extends React.Component {
/>
<a
className='file-preview__remove'
- onClick={this.handleRemove}
+ onClick={this.handleRemove.bind(this, clientId)}
>
<i className='fa fa-remove'/>
</a>
@@ -111,11 +92,11 @@ export default class FilePreview extends React.Component {
}
FilePreview.defaultProps = {
- files: [],
+ fileInfos: [],
uploadsInProgress: []
};
FilePreview.propTypes = {
onRemove: React.PropTypes.func.isRequired,
- files: React.PropTypes.array,
+ fileInfos: React.PropTypes.arrayOf(React.PropTypes.object).isRequired,
uploadsInProgress: React.PropTypes.array
};
diff --git a/webapp/components/file_upload.jsx b/webapp/components/file_upload.jsx
index 39abec7e4..9eff25ab5 100644
--- a/webapp/components/file_upload.jsx
+++ b/webapp/components/file_upload.jsx
@@ -49,13 +49,12 @@ class FileUpload extends React.Component {
this.keyUpload = this.keyUpload.bind(this);
this.state = {
- maxFileSize: global.window.mm_config.MaxFileSize,
requests: {}
};
}
fileUploadSuccess(channelId, data) {
- this.props.onFileUpload(data.filenames, data.client_ids, channelId);
+ this.props.onFileUpload(data.file_infos, data.client_ids, channelId);
const requests = Object.assign({}, this.state.requests);
for (var j = 0; j < data.client_ids.length; j++) {
@@ -81,7 +80,7 @@ class FileUpload extends React.Component {
const tooLargeFiles = [];
for (let i = 0; i < files.length && numUploads < uploadsRemaining; i++) {
- if (files[i].size > this.state.maxFileSize) {
+ if (files[i].size > global.mm_config.MaxFileSize) {
tooLargeFiles.push(files[i]);
continue;
}
@@ -112,9 +111,9 @@ class FileUpload extends React.Component {
} else if (tooLargeFiles.length > 1) {
var tooLargeFilenames = tooLargeFiles.map((file) => file.name).join(', ');
- this.props.onUploadError(formatMessage(holders.filesAbove, {max: (this.state.maxFileSize / 1048576), filenames: tooLargeFilenames}));
+ this.props.onUploadError(formatMessage(holders.filesAbove, {max: (global.mm_config.MaxFileSize / 1048576), filenames: tooLargeFilenames}));
} else if (tooLargeFiles.length > 0) {
- this.props.onUploadError(formatMessage(holders.fileAbove, {max: (this.state.maxFileSize / 1048576), filename: tooLargeFiles[0].name}));
+ this.props.onUploadError(formatMessage(holders.fileAbove, {max: (global.mm_config.MaxFileSize / 1048576), filename: tooLargeFiles[0].name}));
}
}
diff --git a/webapp/components/get_public_link_modal.jsx b/webapp/components/get_public_link_modal.jsx
index 49fd891be..851a78f80 100644
--- a/webapp/components/get_public_link_modal.jsx
+++ b/webapp/components/get_public_link_modal.jsx
@@ -23,7 +23,7 @@ export default class GetPublicLinkModal extends React.Component {
this.state = {
show: false,
- filename: '',
+ fileId: '',
link: ''
};
}
@@ -34,7 +34,7 @@ export default class GetPublicLinkModal extends React.Component {
componentDidUpdate(prevProps, prevState) {
if (this.state.show && !prevState.show) {
- AsyncClient.getPublicLink(decodeURIComponent(this.state.filename), this.handlePublicLink);
+ AsyncClient.getPublicLink(this.state.fileId, this.handlePublicLink);
}
}
@@ -51,7 +51,7 @@ export default class GetPublicLinkModal extends React.Component {
handleToggle(value, args) {
this.setState({
show: value,
- filename: args.filename,
+ fileId: args.fileId,
link: ''
});
}
diff --git a/webapp/components/pdf_preview.jsx b/webapp/components/pdf_preview.jsx
index 7f0f06c03..2cb0a324c 100644
--- a/webapp/components/pdf_preview.jsx
+++ b/webapp/components/pdf_preview.jsx
@@ -3,8 +3,6 @@
import FileInfoPreview from './file_info_preview.jsx';
-import * as Utils from 'utils/utils.jsx';
-
import loadingGif from 'images/load.gif';
import React from 'react';
@@ -109,18 +107,8 @@ export default class PDFPreview extends React.Component {
}
}
- static support(filename) {
- const fileInfo = Utils.splitFileLocation(filename);
- const ext = fileInfo.ext;
- if (!ext) {
- return false;
- }
-
- if (ext === 'pdf') {
- return true;
- }
-
- return false;
+ static supports(fileInfo) {
+ return fileInfo.extension === 'pdf';
}
render() {
@@ -138,10 +126,8 @@ export default class PDFPreview extends React.Component {
if (!this.state.success) {
return (
<FileInfoPreview
- filename={this.props.filename}
- fileUrl={this.props.fileUrl}
fileInfo={this.props.fileInfo}
- formatMessage={this.props.formatMessage}
+ fileUrl={this.props.fileUrl}
/>
);
}
@@ -185,8 +171,6 @@ export default class PDFPreview extends React.Component {
}
PDFPreview.propTypes = {
- filename: React.PropTypes.string.isRequired,
- fileUrl: React.PropTypes.string.isRequired,
fileInfo: React.PropTypes.object.isRequired,
- formatMessage: React.PropTypes.func.isRequired
+ fileUrl: React.PropTypes.string.isRequired
};
diff --git a/webapp/components/post_view/components/commented_on_files_message_container.jsx b/webapp/components/post_view/components/commented_on_files_message_container.jsx
new file mode 100644
index 000000000..5325a7644
--- /dev/null
+++ b/webapp/components/post_view/components/commented_on_files_message_container.jsx
@@ -0,0 +1,88 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import React from 'react';
+
+import * as AsyncClient from 'utils/async_client.jsx';
+import FileStore from 'stores/file_store.jsx';
+import * as Utils from 'utils/utils.jsx';
+
+export default class CommentedOnFilesMessageContainer extends React.Component {
+ static propTypes = {
+ parentPostChannelId: React.PropTypes.string.isRequired,
+ parentPostId: React.PropTypes.string.isRequired
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.handleFileChange = this.handleFileChange.bind(this);
+
+ this.state = {
+ fileInfos: FileStore.getInfosForPost(this.props.parentPostId)
+ };
+ }
+
+ componentDidMount() {
+ FileStore.addChangeListener(this.handleFileChange);
+
+ if (!FileStore.hasInfosForPost(this.props.parentPostId)) {
+ AsyncClient.getFileInfosForPost(this.props.parentPostChannelId, this.props.parentPostId);
+ }
+ }
+
+ componentWillReceiveProps(nextProps) {
+ if (nextProps.parentPostId !== this.props.parentPostId) {
+ this.setState({
+ fileInfos: FileStore.getInfosForPost(this.props.parentPostId)
+ });
+
+ if (!FileStore.hasInfosForPost(this.props.parentPostId)) {
+ AsyncClient.getFileInfosForPost(this.props.parentPostChannelId, this.props.parentPostId);
+ }
+ }
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ if (nextProps.parentPostId !== this.props.parentPostId) {
+ return true;
+ }
+
+ if (nextProps.parentPostChannelId !== this.props.parentPostChannelId) {
+ return true;
+ }
+
+ // fileInfos are treated as immutable by the FileStore
+ if (nextState.fileInfos !== this.state.fileInfos) {
+ return true;
+ }
+
+ return false;
+ }
+
+ handleFileChange() {
+ this.setState({
+ fileInfos: FileStore.getInfosForPost(this.props.parentPostId)
+ });
+ }
+
+ componentWillUnmount() {
+ FileStore.removeChangeListener(this.handleFileChange);
+ }
+
+ render() {
+ let message = ' ';
+
+ if (this.state.fileInfos && this.state.fileInfos.length > 0) {
+ message = this.state.fileInfos[0].name;
+
+ if (this.state.fileInfos.length === 2) {
+ message += Utils.localizeMessage('post_body.plusOne', ' plus 1 other file');
+ } else if (this.state.fileInfos.length > 2) {
+ message += Utils.localizeMessage('post_body.plusMore', ' plus {count} other files').replace('{count}', (this.state.fileInfos.length - 1).toString());
+ }
+ }
+
+ return <span>{message}</span>;
+ }
+}
diff --git a/webapp/components/post_view/components/post_body.jsx b/webapp/components/post_view/components/post_body.jsx
index 5c02e9c40..c23939c1f 100644
--- a/webapp/components/post_view/components/post_body.jsx
+++ b/webapp/components/post_view/components/post_body.jsx
@@ -1,11 +1,12 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
-import FileAttachmentList from 'components/file_attachment_list.jsx';
import UserStore from 'stores/user_store.jsx';
import * as Utils from 'utils/utils.jsx';
import * as GlobalActions from 'actions/global_actions.jsx';
import Constants from 'utils/constants.jsx';
+import CommentedOnFilesMessageContainer from './commented_on_files_message_container.jsx';
+import FileAttachmentListContainer from 'components/file_attachment_list_container.jsx';
import PostBodyAdditionalContent from './post_body_additional_content.jsx';
import PostMessageContainer from './post_message_container.jsx';
import PendingPostOptions from './pending_post_options.jsx';
@@ -22,6 +23,7 @@ export default class PostBody extends React.Component {
this.removePost = this.removePost.bind(this);
}
+
shouldComponentUpdate(nextProps) {
if (nextProps.isCommentMention !== this.props.isCommentMention) {
return true;
@@ -56,7 +58,6 @@ export default class PostBody extends React.Component {
render() {
const post = this.props.post;
- const filenames = this.props.post.filenames;
const parentPost = this.props.parentPost;
let comment = '';
@@ -94,14 +95,13 @@ export default class PostBody extends React.Component {
let message = '';
if (parentPost.message) {
message = Utils.replaceHtmlEntities(parentPost.message);
- } else if (parentPost.filenames.length) {
- message = parentPost.filenames[0].split('/').pop();
-
- if (parentPost.filenames.length === 2) {
- message += Utils.localizeMessage('post_body.plusOne', ' plus 1 other file');
- } else if (parentPost.filenames.length > 2) {
- message += Utils.localizeMessage('post_body.plusMore', ' plus {count} other files').replace('{count}', (parentPost.filenames.length - 1).toString());
- }
+ } else if (parentPost.file_ids && parentPost.file_ids.length > 0) {
+ message = (
+ <CommentedOnFilesMessageContainer
+ parentPostChannelId={parentPost.channel_id}
+ parentPostId={parentPost.id}
+ />
+ );
}
comment = (
@@ -140,14 +140,11 @@ export default class PostBody extends React.Component {
);
}
- let fileAttachmentHolder = '';
- if (filenames && filenames.length > 0) {
+ let fileAttachmentHolder = null;
+ if ((post.file_ids && post.file_ids.length > 0) || (post.filenames && post.filenames.length > 0)) {
fileAttachmentHolder = (
- <FileAttachmentList
-
- filenames={filenames}
- channelId={post.channel_id}
- userId={post.user_id}
+ <FileAttachmentListContainer
+ post={post}
compactDisplay={this.props.compactDisplay}
/>
);
diff --git a/webapp/components/rhs_comment.jsx b/webapp/components/rhs_comment.jsx
index e1af1227b..18e4b4d1c 100644
--- a/webapp/components/rhs_comment.jsx
+++ b/webapp/components/rhs_comment.jsx
@@ -2,7 +2,7 @@
// See License.txt for license information.
import UserProfile from './user_profile.jsx';
-import FileAttachmentList from './file_attachment_list.jsx';
+import FileAttachmentListContainer from './file_attachment_list_container.jsx';
import PendingPostOptions from 'components/post_view/components/pending_post_options.jsx';
import PostMessageContainer from 'components/post_view/components/post_message_container.jsx';
import ProfilePicture from 'components/profile_picture.jsx';
@@ -295,13 +295,11 @@ export default class RhsComment extends React.Component {
var dropdown = this.createDropdown();
- var fileAttachment;
- if (post.filenames && post.filenames.length > 0) {
+ let fileAttachment = null;
+ if (post.file_ids && post.file_ids.length > 0) {
fileAttachment = (
- <FileAttachmentList
- filenames={post.filenames}
- channelId={post.channel_id}
- userId={post.user_id}
+ <FileAttachmentListContainer
+ post={post}
compactDisplay={this.props.compactDisplay}
/>
);
diff --git a/webapp/components/rhs_root_post.jsx b/webapp/components/rhs_root_post.jsx
index 09ab17ba5..983469f50 100644
--- a/webapp/components/rhs_root_post.jsx
+++ b/webapp/components/rhs_root_post.jsx
@@ -4,7 +4,7 @@
import UserProfile from './user_profile.jsx';
import PostBodyAdditionalContent from 'components/post_view/components/post_body_additional_content.jsx';
import PostMessageContainer from 'components/post_view/components/post_message_container.jsx';
-import FileAttachmentList from './file_attachment_list.jsx';
+import FileAttachmentListContainer from './file_attachment_list_container.jsx';
import ProfilePicture from 'components/profile_picture.jsx';
import ChannelStore from 'stores/channel_store.jsx';
@@ -242,13 +242,11 @@ export default class RhsRootPost extends React.Component {
);
}
- var fileAttachment;
- if (post.filenames && post.filenames.length > 0) {
+ let fileAttachment = null;
+ if (post.file_ids && post.file_ids.length > 0) {
fileAttachment = (
- <FileAttachmentList
- filenames={post.filenames}
- channelId={post.channel_id}
- userId={post.user_id}
+ <FileAttachmentListContainer
+ post={post}
compactDisplay={this.props.compactDisplay}
/>
);
diff --git a/webapp/components/view_image.jsx b/webapp/components/view_image.jsx
index c9f558725..385138d54 100644
--- a/webapp/components/view_image.jsx
+++ b/webapp/components/view_image.jsx
@@ -11,7 +11,6 @@ import * as GlobalActions from 'actions/global_actions.jsx';
import FileStore from 'stores/file_store.jsx';
-import * as AsyncClient from 'utils/async_client.jsx';
import * as Utils from 'utils/utils.jsx';
import Constants from 'utils/constants.jsx';
@@ -19,19 +18,11 @@ const KeyCodes = Constants.KeyCodes;
import $ from 'jquery';
import React from 'react';
-import {intlShape, injectIntl, defineMessages} from 'react-intl';
import {Modal} from 'react-bootstrap';
import loadingGif from 'images/load.gif';
-const holders = defineMessages({
- loading: {
- id: 'view_image.loading',
- defaultMessage: 'Loading '
- }
-});
-
-class ViewImageModal extends React.Component {
+export default class ViewImageModal extends React.Component {
constructor(props) {
super(props);
@@ -45,18 +36,15 @@ class ViewImageModal extends React.Component {
this.onModalShown = this.onModalShown.bind(this);
this.onModalHidden = this.onModalHidden.bind(this);
- this.onFileStoreChange = this.onFileStoreChange.bind(this);
-
this.handleGetPublicLink = this.handleGetPublicLink.bind(this);
this.onMouseEnterImage = this.onMouseEnterImage.bind(this);
this.onMouseLeaveImage = this.onMouseLeaveImage.bind(this);
this.state = {
imgId: this.props.startId,
- fileInfo: null,
imgHeight: '100%',
- loaded: Utils.fillArray(false, this.props.filenames.length),
- progress: Utils.fillArray(0, this.props.filenames.length),
+ loaded: Utils.fillArray(false, this.props.fileInfos.length),
+ progress: Utils.fillArray(0, this.props.fileInfos.length),
showFooter: false
};
}
@@ -66,7 +54,7 @@ class ViewImageModal extends React.Component {
e.stopPropagation();
}
let id = this.state.imgId + 1;
- if (id > this.props.filenames.length - 1) {
+ if (id > this.props.fileInfos.length - 1) {
id = 0;
}
this.showImage(id);
@@ -78,7 +66,7 @@ class ViewImageModal extends React.Component {
}
let id = this.state.imgId - 1;
if (id < 0) {
- id = this.props.filenames.length - 1;
+ id = this.props.fileInfos.length - 1;
}
this.showImage(id);
}
@@ -95,8 +83,6 @@ class ViewImageModal extends React.Component {
$(window).on('keyup', this.handleKeyPress);
this.showImage(nextProps.startId);
-
- FileStore.addChangeListener(this.onFileStoreChange);
}
onModalHidden() {
@@ -105,8 +91,6 @@ class ViewImageModal extends React.Component {
if (this.refs.video) {
this.refs.video.stop();
}
-
- FileStore.removeChangeListener(this.onFileStoreChange);
}
componentWillReceiveProps(nextProps) {
@@ -116,64 +100,36 @@ class ViewImageModal extends React.Component {
this.onModalHidden();
}
- if (!Utils.areObjectsEqual(this.props.filenames, nextProps.filenames)) {
+ if (this.props.fileInfos !== nextProps.fileInfos) {
this.setState({
- loaded: Utils.fillArray(false, nextProps.filenames.length),
- progress: Utils.fillArray(0, nextProps.filenames.length)
+ loaded: Utils.fillArray(false, nextProps.fileInfos.length),
+ progress: Utils.fillArray(0, nextProps.fileInfos.length)
});
}
}
- onFileStoreChange(filename) {
- const id = this.props.filenames.indexOf(filename);
-
- if (id !== -1) {
- if (id === this.state.imgId) {
- this.setState({
- fileInfo: FileStore.getInfo(filename)
- });
- }
-
- if (!this.state.loaded[id]) {
- this.loadImage(id, filename);
- }
- }
- }
-
showImage(id) {
this.setState({imgId: id});
const imgHeight = $(window).height() - 100;
this.setState({imgHeight});
- const filename = this.props.filenames[id];
-
- if (!FileStore.hasInfo(filename)) {
- // the image will actually be loaded once we know what we need to load
- AsyncClient.getFileInfo(filename);
- return;
- }
-
- this.setState({
- fileInfo: FileStore.getInfo(filename)
- });
-
if (!this.state.loaded[id]) {
- this.loadImage(id, filename);
+ this.loadImage(id);
}
}
- loadImage(id, filename) {
- const fileInfo = FileStore.getInfo(filename);
+ loadImage(index) {
+ const fileInfo = this.props.fileInfos[index];
const fileType = Utils.getFileType(fileInfo.extension);
if (fileType === 'image') {
let previewUrl;
if (fileInfo.has_image_preview) {
- previewUrl = Utils.getPreviewImagePath(filename);
+ previewUrl = FileStore.getFilePreviewUrl(fileInfo.id);
} else {
// some images (eg animated gifs) just show the file itself and not a preview
- previewUrl = Utils.getFileUrl(filename);
+ previewUrl = FileStore.getFileUrl(fileInfo.id);
}
const img = new Image();
@@ -181,19 +137,19 @@ class ViewImageModal extends React.Component {
previewUrl,
() => {
const progress = this.state.progress;
- progress[id] = img.completedPercentage;
+ progress[index] = img.completedPercentage;
this.setState({progress});
}
);
img.onload = () => {
const loaded = this.state.loaded;
- loaded[id] = true;
+ loaded[index] = true;
this.setState({loaded});
};
} else {
// there's nothing to load for non-image files
var loaded = this.state.loaded;
- loaded[id] = true;
+ loaded[index] = true;
this.setState({loaded});
}
}
@@ -201,7 +157,7 @@ class ViewImageModal extends React.Component {
handleGetPublicLink() {
this.props.onModalDismissed();
- GlobalActions.showGetPublicLinkModal(this.props.filenames[this.state.imgId]);
+ GlobalActions.showGetPublicLinkModal(this.props.fileInfos[this.state.imgId].id);
}
onMouseEnterImage() {
@@ -213,63 +169,52 @@ class ViewImageModal extends React.Component {
}
render() {
- if (this.props.filenames.length < 1 || this.props.filenames.length - 1 < this.state.imgId) {
- return <div/>;
+ if (this.props.fileInfos.length < 1 || this.props.fileInfos.length - 1 < this.state.imgId) {
+ return null;
}
- const filename = this.props.filenames[this.state.imgId];
- const fileUrl = Utils.getFileUrl(filename);
+ const fileInfo = this.props.fileInfos[this.state.imgId];
+ const fileUrl = FileStore.getFileUrl(fileInfo.id);
- var content;
+ let content;
if (this.state.loaded[this.state.imgId]) {
- // this.state.fileInfo is for the current image and we shoudl have it before we load the image
- const fileInfo = this.state.fileInfo;
const fileType = Utils.getFileType(fileInfo.extension);
if (fileType === 'image') {
content = (
<ImagePreview
- filename={filename}
- fileUrl={fileUrl}
fileInfo={fileInfo}
+ fileUrl={fileUrl}
maxHeight={this.state.imgHeight}
/>
);
} else if (fileType === 'video' || fileType === 'audio') {
content = (
<AudioVideoPreview
- filename={filename}
+ fileInfo={fileInfo}
fileUrl={fileUrl}
- fileInfo={this.state.fileInfo}
maxHeight={this.state.imgHeight}
- formatMessage={this.props.intl.formatMessage}
/>
);
- } else if (PDFPreview.support(filename)) {
+ } else if (PDFPreview.supports(fileInfo)) {
content = (
<PDFPreview
- filename={filename}
- fileUrl={fileUrl}
fileInfo={fileInfo}
- formatMessage={this.props.intl.formatMessage}
+ fileUrl={fileUrl}
/>
);
- } else if (CodePreview.support(filename)) {
+ } else if (CodePreview.supports(fileInfo)) {
content = (
<CodePreview
- filename={filename}
- fileUrl={fileUrl}
fileInfo={fileInfo}
- formatMessage={this.props.intl.formatMessage}
+ fileUrl={fileUrl}
/>
);
} else {
content = (
<FileInfoPreview
- filename={filename}
- fileUrl={fileUrl}
fileInfo={fileInfo}
- formatMessage={this.props.intl.formatMessage}
+ fileUrl={fileUrl}
/>
);
}
@@ -280,14 +225,14 @@ class ViewImageModal extends React.Component {
content = (
<LoadingImagePreview
progress={progress}
- loading={this.props.intl.formatMessage(holders.loading)}
+ loading={Utils.localizeMessage('view_image.loading', 'Loading ')}
/>
);
}
let leftArrow = null;
let rightArrow = null;
- if (this.props.filenames.length > 1) {
+ if (this.props.fileInfos.length > 1) {
leftArrow = (
<a
ref='previewArrowLeft'
@@ -346,8 +291,8 @@ class ViewImageModal extends React.Component {
<ViewImagePopoverBar
show={this.state.showFooter}
fileId={this.state.imgId}
- totalFiles={this.props.filenames.length}
- filename={name}
+ totalFiles={this.props.fileInfos.length}
+ filename={fileInfo.name}
fileURL={fileUrl}
onGetPublicLink={this.handleGetPublicLink}
/>
@@ -363,19 +308,13 @@ class ViewImageModal extends React.Component {
ViewImageModal.defaultProps = {
show: false,
- filenames: [],
- channelId: '',
- userId: '',
+ fileInfos: [],
startId: 0
};
ViewImageModal.propTypes = {
- intl: intlShape.isRequired,
show: React.PropTypes.bool.isRequired,
onModalDismissed: React.PropTypes.func.isRequired,
- filenames: React.PropTypes.array,
- modalId: React.PropTypes.string,
- channelId: React.PropTypes.string,
- userId: React.PropTypes.string,
+ fileInfos: React.PropTypes.arrayOf(React.PropTypes.object).isRequired,
startId: React.PropTypes.number
};
@@ -405,10 +344,10 @@ LoadingImagePreview.propTypes = {
loading: React.PropTypes.string
};
-function ImagePreview({filename, fileUrl, fileInfo, maxHeight}) {
+function ImagePreview({fileInfo, fileUrl, maxHeight}) {
let previewUrl;
if (fileInfo.has_preview_image) {
- previewUrl = Utils.getPreviewImagePath(filename);
+ previewUrl = FileStore.getFilePreviewUrl(fileInfo.id);
} else {
previewUrl = fileUrl;
}
@@ -429,10 +368,7 @@ function ImagePreview({filename, fileUrl, fileInfo, maxHeight}) {
}
ImagePreview.propTypes = {
- filename: React.PropTypes.string.isRequired,
- fileUrl: React.PropTypes.string.isRequired,
fileInfo: React.PropTypes.object.isRequired,
+ fileUrl: React.PropTypes.string.isRequired,
maxHeight: React.PropTypes.number.isRequired
};
-
-export default injectIntl(ViewImageModal);
diff --git a/webapp/sass/components/_files.scss b/webapp/sass/components/_files.scss
index 7b7588087..e710838b1 100644
--- a/webapp/sass/components/_files.scss
+++ b/webapp/sass/components/_files.scss
@@ -166,6 +166,10 @@
margin: 5px 10px 5px 0;
position: relative;
width: 240px;
+
+ &--placeholder {
+ visibility: hidden;
+ }
}
.post-image__load {
diff --git a/webapp/stores/file_store.jsx b/webapp/stores/file_store.jsx
index 2692e6959..18a35e1fd 100644
--- a/webapp/stores/file_store.jsx
+++ b/webapp/stores/file_store.jsx
@@ -16,41 +16,58 @@ class FileStore extends EventEmitter {
this.handleEventPayload = this.handleEventPayload.bind(this);
this.dispatchToken = AppDispatcher.register(this.handleEventPayload);
- this.fileInfo = new Map();
+ this.setMaxListeners(600);
+
+ this.fileInfosByPost = new Map();
}
addChangeListener(callback) {
this.on(CHANGE_EVENT, callback);
}
+
removeChangeListener(callback) {
this.removeListener(CHANGE_EVENT, callback);
}
- emitChange(filename) {
- this.emit(CHANGE_EVENT, filename);
+
+ emitChange() {
+ this.emit(CHANGE_EVENT);
+ }
+
+ hasInfosForPost(postId) {
+ return this.fileInfosByPost.has(postId);
+ }
+
+ getInfosForPost(postId) {
+ return this.fileInfosByPost.get(postId);
+ }
+
+ saveInfos(postId, infos) {
+ this.fileInfosByPost.set(postId, infos);
}
- hasInfo(filename) {
- return this.fileInfo.has(filename);
+ getFileUrl(fileId) {
+ return `/api/v3/files/${fileId}/get`;
}
- getInfo(filename) {
- return this.fileInfo.get(filename);
+ getFileThumbnailUrl(fileId) {
+ return `/api/v3/files/${fileId}/get_thumbnail`;
}
- setInfo(filename, info) {
- this.fileInfo.set(filename, info);
+ getFilePreviewUrl(fileId) {
+ return `/api/v3/files/${fileId}/get_preview`;
}
handleEventPayload(payload) {
const action = payload.action;
switch (action.type) {
- case ActionTypes.RECEIVED_FILE_INFO:
- this.setInfo(action.filename, action.info);
- this.emitChange(action.filename);
+ case ActionTypes.RECEIVED_FILE_INFOS:
+ // This assumes that all received file infos are for a single post
+ this.saveInfos(action.postId, action.infos);
+ this.emitChange(action.postId);
break;
}
}
}
-export default new FileStore();
+export default new FileStore(); \ No newline at end of file
diff --git a/webapp/stores/notification_store.jsx b/webapp/stores/notification_store.jsx
index c5122dd7a..917b86df8 100644
--- a/webapp/stores/notification_store.jsx
+++ b/webapp/stores/notification_store.jsx
@@ -84,7 +84,7 @@ class NotificationStoreClass extends EventEmitter {
if (msgProps.image) {
body = username + Utils.localizeMessage('channel_loader.uploadedImage', ' uploaded an image');
} else if (msgProps.otherFile) {
- body = Utils.localizeMessage('channel_loader.uploadedFile', ' uploaded a file');
+ body = username + Utils.localizeMessage('channel_loader.uploadedFile', ' uploaded a file');
} else {
body = username + Utils.localizeMessage('channel_loader.something', ' did something new');
}
diff --git a/webapp/stores/post_store.jsx b/webapp/stores/post_store.jsx
index 62283dacd..22f47fd40 100644
--- a/webapp/stores/post_store.jsx
+++ b/webapp/stores/post_store.jsx
@@ -224,7 +224,7 @@ class PostStoreClass extends EventEmitter {
} else if (combinedPosts.posts.hasOwnProperty(pid)) {
combinedPosts.posts[pid] = Object.assign({}, np, {
state: Constants.POST_DELETED,
- filenames: []
+ fileIds: []
});
}
}
@@ -318,7 +318,7 @@ class PostStoreClass extends EventEmitter {
// make sure to copy the post so that component state changes work properly
postList.posts[post.id] = Object.assign({}, post, {
state: Constants.POST_DELETED,
- filenames: []
+ fileIds: []
});
}
}
@@ -514,7 +514,7 @@ class PostStoreClass extends EventEmitter {
}
getEmptyDraft() {
- return {message: '', uploadsInProgress: [], previews: []};
+ return {message: '', uploadsInProgress: [], fileInfos: []};
}
storeCurrentDraft(draft) {
diff --git a/webapp/tests/client_file.test.jsx b/webapp/tests/client_file.test.jsx
new file mode 100644
index 000000000..fac70d19c
--- /dev/null
+++ b/webapp/tests/client_file.test.jsx
@@ -0,0 +1,248 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import assert from 'assert';
+import TestHelper from './test_helper.jsx';
+
+const fs = require('fs');
+
+describe('Client.File', function() {
+ this.timeout(100000);
+
+ before(function() {
+ // write a temporary file so that we have something to upload for testing
+ const buffer = new Buffer('R0lGODlhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=', 'base64');
+
+ const testGif = fs.openSync('test.gif', 'w+');
+ fs.writeFileSync(testGif, buffer);
+ });
+
+ after(function() {
+ fs.unlinkSync('test.gif');
+ });
+
+ it('uploadFile', function(done) {
+ TestHelper.initBasic(() => {
+ const clientId = TestHelper.generateId();
+
+ TestHelper.basicClient().uploadFile(
+ fs.createReadStream('test.gif'),
+ 'test.gif',
+ TestHelper.basicChannel().id,
+ clientId,
+ function(resp) {
+ assert.equal(resp.file_infos.length, 1);
+ assert.equal(resp.client_ids.length, 1);
+ assert.equal(resp.client_ids[0], clientId);
+
+ done();
+ },
+ function(err) {
+ done(new Error(err.message));
+ }
+ );
+ });
+ });
+
+ it('getFile', function(done) {
+ TestHelper.initBasic(() => {
+ TestHelper.basicClient().uploadFile(
+ fs.createReadStream('test.gif'),
+ 'test.gif',
+ TestHelper.basicChannel().id,
+ '',
+ function(resp) {
+ TestHelper.basicClient().getFile(
+ resp.file_infos[0].id,
+ function() {
+ done();
+ },
+ function(err2) {
+ done(new Error(err2.message));
+ }
+ );
+ },
+ function(err) {
+ done(new Error(err.message));
+ }
+ );
+ });
+ });
+
+ it('getFileThumbnail', function(done) {
+ TestHelper.initBasic(() => {
+ TestHelper.basicClient().uploadFile(
+ fs.createReadStream('test.gif'),
+ 'test.gif',
+ TestHelper.basicChannel().id,
+ '',
+ function(resp) {
+ TestHelper.basicClient().getFileThumbnail(
+ resp.file_infos[0].id,
+ function() {
+ done();
+ },
+ function(err) {
+ done(new Error(err.message));
+ }
+ );
+ },
+ function(err) {
+ done(new Error(err.message));
+ }
+ );
+ });
+ });
+
+ it('getFilePreview', function(done) {
+ TestHelper.initBasic(() => {
+ TestHelper.basicClient().uploadFile(
+ fs.createReadStream('test.gif'),
+ 'test.gif',
+ TestHelper.basicChannel().id,
+ '',
+ function(resp) {
+ TestHelper.basicClient().getFilePreview(
+ resp.file_infos[0].id,
+ function() {
+ done();
+ },
+ function(err2) {
+ done(new Error(err2.message));
+ }
+ );
+ },
+ function(err) {
+ done(new Error(err.message));
+ }
+ );
+ });
+ });
+
+ it('getFileInfo', function(done) {
+ TestHelper.initBasic(() => {
+ TestHelper.basicClient().uploadFile(
+ fs.createReadStream('test.gif'),
+ 'test.gif',
+ TestHelper.basicChannel().id,
+ '',
+ function(resp) {
+ const fileId = resp.file_infos[0].id;
+
+ TestHelper.basicClient().getFileInfo(
+ fileId,
+ function(info) {
+ assert.equal(info.id, fileId);
+ assert.equal(info.name, 'test.gif');
+
+ done();
+ },
+ function(err2) {
+ done(new Error(err2.message));
+ }
+ );
+ },
+ function(err) {
+ done(new Error(err.message));
+ }
+ );
+ });
+ });
+
+ it('getPublicLink', function(done) {
+ TestHelper.initBasic(() => {
+ TestHelper.basicClient().uploadFile(
+ fs.createReadStream('test.gif'),
+ 'test.gif',
+ TestHelper.basicChannel().id,
+ '',
+ function(resp) {
+ const post = TestHelper.fakePost();
+ post.channel_id = TestHelper.basicChannel().id;
+ post.file_ids = resp.file_infos.map((info) => info.id);
+
+ TestHelper.basicClient().createPost(
+ post,
+ function(data) {
+ assert.deepEqual(data.file_ids, post.file_ids);
+
+ TestHelper.basicClient().getPublicLink(
+ post.file_ids[0],
+ function() {
+ done(new Error('public links should be disabled by default'));
+
+ // request.
+ // get(link).
+ // end(TestHelper.basicChannel().handleResponse.bind(
+ // this,
+ // 'getPublicLink',
+ // function() {
+ // done();
+ // },
+ // function(err4) {
+ // done(new Error(err4.message));
+ // }
+ // ));
+ },
+ function() {
+ done();
+
+ // done(new Error(err3.message));
+ }
+ );
+ },
+ function(err2) {
+ done(new Error(err2.message));
+ }
+ );
+ },
+ function(err) {
+ done(new Error(err.message));
+ }
+ );
+ });
+ });
+
+ it('getFileInfosForPost', function(done) {
+ TestHelper.initBasic(() => {
+ TestHelper.basicClient().uploadFile(
+ fs.createReadStream('test.gif'),
+ 'test.gif',
+ TestHelper.basicChannel().id,
+ '',
+ function(resp) {
+ const post = TestHelper.fakePost();
+ post.channel_id = TestHelper.basicChannel().id;
+ post.file_ids = resp.file_infos.map((info) => info.id);
+
+ TestHelper.basicClient().createPost(
+ post,
+ function(data) {
+ assert.deepEqual(data.file_ids, post.file_ids);
+
+ TestHelper.basicClient().getFileInfosForPost(
+ post.channel_id,
+ data.id,
+ function(files) {
+ assert.equal(files.length, 1);
+ assert.equal(files[0].id, resp.file_infos[0].id);
+
+ done();
+ },
+ function(err3) {
+ done(new Error(err3.message));
+ }
+ );
+ },
+ function(err2) {
+ done(new Error(err2.message));
+ }
+ );
+ },
+ function(err) {
+ done(new Error(err.message));
+ }
+ );
+ });
+ });
+});
diff --git a/webapp/tests/client_general.test.jsx b/webapp/tests/client_general.test.jsx
index 61e7832da..709583c11 100644
--- a/webapp/tests/client_general.test.jsx
+++ b/webapp/tests/client_general.test.jsx
@@ -43,43 +43,5 @@ describe('Client.General', function() {
done();
});
});
-
- it('File.getFileInfo', function(done) {
- TestHelper.initBasic(() => {
- TestHelper.basicClient().enableLogErrorsToConsole(false); // Disabling since this unit test causes an error
-
- TestHelper.basicClient().getFileInfo(
- `/${TestHelper.basicChannel().id}/${TestHelper.basicUser().id}/filename.txt`,
- function(data) {
- assert.equal(data.filename, 'filename.txt');
- done();
- },
- function(err) {
- done(new Error(err.message));
- }
- );
- });
- });
-
- it('File.getPublicLink', function(done) {
- TestHelper.initBasic(() => {
- TestHelper.basicClient().enableLogErrorsToConsole(false); // Disabling since this unit test causes an error
- var data = {};
- data.channel_id = TestHelper.basicChannel().id;
- data.user_id = TestHelper.basicUser().id;
- data.filename = `/${TestHelper.basicChannel().id}/${TestHelper.basicUser().id}/filename.txt`;
-
- TestHelper.basicClient().getPublicLink(
- data,
- function() {
- done(new Error('not enabled'));
- },
- function(err) {
- assert.equal(err.id, 'api.file.get_public_link.disabled.app_error');
- done();
- }
- );
- });
- });
});
diff --git a/webapp/tests/client_post.test.jsx b/webapp/tests/client_post.test.jsx
index 3b9802fb4..afe10931f 100644
--- a/webapp/tests/client_post.test.jsx
+++ b/webapp/tests/client_post.test.jsx
@@ -230,5 +230,7 @@ describe('Client.Posts', function() {
);
});
});
+
+ // getFileInfosForPost is tested in client_files.test.jsx
});
diff --git a/webapp/utils/async_client.jsx b/webapp/utils/async_client.jsx
index 22b223bc5..5441f260c 100644
--- a/webapp/utils/async_client.jsx
+++ b/webapp/utils/async_client.jsx
@@ -715,6 +715,32 @@ export function getPostsAfter(postId, offset, numPost, isPost) {
);
}
+export function getFileInfosForPost(channelId, postId) {
+ const callName = 'getFileInfosForPost' + postId;
+
+ if (isCallInProgress(callName)) {
+ return;
+ }
+
+ Client.getFileInfosForPost(
+ channelId,
+ postId,
+ (data) => {
+ callTracker[callName] = 0;
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.RECEIVED_FILE_INFOS,
+ postId,
+ infos: data
+ });
+ },
+ (err) => {
+ callTracker[callName] = 0;
+ dispatchError(err, 'getPostFile');
+ }
+ );
+}
+
export function getMe() {
if (isCallInProgress('getMe')) {
return null;
@@ -923,34 +949,6 @@ export function getSuggestedCommands(command, suggestionId, component) {
);
}
-export function getFileInfo(filename) {
- const callName = 'getFileInfo' + filename;
-
- if (isCallInProgress(callName)) {
- return;
- }
-
- callTracker[callName] = utils.getTimestamp();
-
- Client.getFileInfo(
- filename,
- (data) => {
- callTracker[callName] = 0;
-
- AppDispatcher.handleServerAction({
- type: ActionTypes.RECEIVED_FILE_INFO,
- filename,
- info: data
- });
- },
- (err) => {
- callTracker[callName] = 0;
-
- dispatchError(err, 'getFileInfo');
- }
- );
-}
-
export function getStandardAnalytics(teamId) {
const callName = 'getStandardAnaytics' + teamId;
@@ -1432,8 +1430,8 @@ export function regenCommandToken(id) {
);
}
-export function getPublicLink(filename, success, error) {
- const callName = 'getPublicLink' + filename;
+export function getPublicLink(fileId, success, error) {
+ const callName = 'getPublicLink' + fileId;
if (isCallInProgress(callName)) {
return;
@@ -1442,7 +1440,7 @@ export function getPublicLink(filename, success, error) {
callTracker[callName] = utils.getTimestamp();
Client.getPublicLink(
- filename,
+ fileId,
(link) => {
callTracker[callName] = 0;
diff --git a/webapp/utils/constants.jsx b/webapp/utils/constants.jsx
index 039d48aaa..2b6e110ce 100644
--- a/webapp/utils/constants.jsx
+++ b/webapp/utils/constants.jsx
@@ -95,7 +95,7 @@ export const ActionTypes = keyMirror({
RECEIVED_PREFERENCE: null,
RECEIVED_PREFERENCES: null,
DELETED_PREFERENCES: null,
- RECEIVED_FILE_INFO: null,
+ RECEIVED_FILE_INFOS: null,
RECEIVED_ANALYTICS: null,
RECEIVED_INCOMING_WEBHOOKS: null,
diff --git a/webapp/utils/syntax_hightlighting.jsx b/webapp/utils/syntax_hightlighting.jsx
index 4db6d11e3..47ba5bd4e 100644
--- a/webapp/utils/syntax_hightlighting.jsx
+++ b/webapp/utils/syntax_hightlighting.jsx
@@ -136,14 +136,9 @@ export function highlight(lang, code) {
return TextFormatting.sanitizeHtml(code);
}
-export function getLanguageFromFilename(filename) {
- const fileSplit = filename.split('.');
-
- let ext = fileSplit.length > 1 ? fileSplit[fileSplit.length - 1] : '';
- ext = ext.toLowerCase();
-
+export function getLanguageFromFileExtension(extension) {
for (var key in HighlightedLanguages) {
- if (HighlightedLanguages[key].extensions.find((x) => x === ext)) {
+ if (HighlightedLanguages[key].extensions.find((x) => x === extension)) {
return key;
}
}
diff --git a/webapp/utils/utils.jsx b/webapp/utils/utils.jsx
index 25a9dfa7d..5a47b0a63 100644
--- a/webapp/utils/utils.jsx
+++ b/webapp/utils/utils.jsx
@@ -413,8 +413,8 @@ export function getFileType(extin) {
return 'other';
}
-export function getPreviewImagePathForFileType(fileTypeIn) {
- var fileType = fileTypeIn.toLowerCase();
+export function getFileIconPath(fileInfo) {
+ const fileType = getFileType(fileInfo.extension);
var icon;
if (fileType in Constants.ICON_FROM_TYPE) {
@@ -451,19 +451,6 @@ export function splitFileLocation(fileLocation) {
return {ext, name: filename, path: filePath};
}
-export function getPreviewImagePath(filename) {
- // Returns the path to a preview image that can be used to represent a file.
- const fileInfo = splitFileLocation(filename);
- const fileType = getFileType(fileInfo.ext);
-
- if (fileType === 'image') {
- return getFileUrl(fileInfo.path + '_preview.jpg');
- }
-
- // only images have proper previews, so just use a placeholder icon for non-images
- return getPreviewImagePathForFileType(fileType);
-}
-
export function toTitleCase(str) {
function doTitleCase(txt) {
return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();