PLT-3105 Files table migration (#4068)

* Implemented initial changes for files table

* Removed *_benchmark_test.go files

* Re-implemented GetPublicFile and added support for old path

* Localization for files table

* Moved file system code into utils package

* Finished server-side changes and added initial upgrade script

* Added getPostFiles api

* Re-add Extension and HasPreviewImage fields to FileInfo

* Removed unused translation

* Fixed merge conflicts left over after permissions changes

* Forced FileInfo.extension to be lower case

* Changed FileUploadResponse to contain the FileInfos instead of FileIds

* Fixed permissions on getFile* calls

* Fixed notifications for file uploads

* Added initial version of client code for files changes

* Permanently added FileIds field to Post object and removed Post.HasFiles

* Updated PostStore.Update to be usable in more circumstances

* Re-added Filenames field and switched file migration to be entirely lazy-loaded

* Increased max listener count for FileStore

* Removed unused fileInfoCache

* Moved file system code back into api

* Removed duplicate test case

* Fixed unit test running on ports other than 8065

* Renamed HasPermissionToPostContext to HasPermissionToChannelByPostContext

* Refactored handleImages to make it more easily understandable

* Renamed getPostFiles to getFileInfosForPost

* Re-added pre-FileIds posts to analytics

* Changed files to be saved as their ids as opposed to id/filename.ext

* Renamed FileInfo.UserId to FileInfo.CreatorId

* Fixed detection of language in CodePreview

* Fixed switching between threads in the RHS not loading new files

* Add serverside protection against a rare bug where the client sends the same file twice for a single post

* Refactored the important parts of uploadFile api call into a function that can be called without a web context
Этот коммит содержится в:
Harrison Healey
2016-09-30 11:06:30 -04:00
коммит произвёл GitHub
родитель a2deeed597
Коммит 8a0e649f98
60 изменённых файлов: 3454 добавлений и 2062 удалений

Просмотреть файл

@@ -33,7 +33,9 @@ type Routes struct {
Commands *mux.Router // 'api/v3/teams/{team_id:[A-Za-z0-9]+}/commands' 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' 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' OAuth *mux.Router // 'api/v3/oauth'
@@ -70,7 +72,9 @@ func InitApi() {
BaseRoutes.Posts = BaseRoutes.NeedChannel.PathPrefix("/posts").Subrouter() BaseRoutes.Posts = BaseRoutes.NeedChannel.PathPrefix("/posts").Subrouter()
BaseRoutes.NeedPost = BaseRoutes.Posts.PathPrefix("/{post_id:[A-Za-z0-9]+}").Subrouter() BaseRoutes.NeedPost = BaseRoutes.Posts.PathPrefix("/{post_id:[A-Za-z0-9]+}").Subrouter()
BaseRoutes.Commands = BaseRoutes.NeedTeam.PathPrefix("/commands").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.Hooks = BaseRoutes.NeedTeam.PathPrefix("/hooks").Subrouter()
BaseRoutes.OAuth = BaseRoutes.ApiRoot.PathPrefix("/oauth").Subrouter() BaseRoutes.OAuth = BaseRoutes.ApiRoot.PathPrefix("/oauth").Subrouter()
BaseRoutes.Admin = BaseRoutes.ApiRoot.PathPrefix("/admin").Subrouter() BaseRoutes.Admin = BaseRoutes.ApiRoot.PathPrefix("/admin").Subrouter()

Просмотреть файл

@@ -114,6 +114,42 @@ func HasPermissionToChannel(user *model.User, teamMember *model.TeamMember, chan
return HasPermissionToTeam(user, teamMember, permission) 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 { func HasPermissionToUser(c *Context, userId string) bool {
// You are the user (users autmaticly have permissions to themselves) // You are the user (users autmaticly have permissions to themselves)
if c.Session.UserId == userId { if c.Session.UserId == userId {

Просмотреть файл

@@ -8,7 +8,6 @@ import (
"github.com/mattermost/platform/model" "github.com/mattermost/platform/model"
"github.com/mattermost/platform/utils" "github.com/mattermost/platform/utils"
"io" "io"
"mime/multipart"
"os" "os"
) )
@@ -40,53 +39,31 @@ func NewAutoPostCreator(client *model.Client, channelid string) *AutoPostCreator
} }
func (cfg *AutoPostCreator) UploadTestFile() ([]string, bool) { 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})] 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") path := utils.FindDir("web/static/images")
file, err := os.Open(path + "/" + filename) file, err := os.Open(path + "/" + filename)
defer file.Close() defer file.Close()
_, err = io.Copy(part, file) data := &bytes.Buffer{}
_, err = io.Copy(data, file)
if err != nil { if err != nil {
return nil, false return nil, false
} }
field, err := writer.CreateFormField("channel_id") resp, appErr := cfg.client.UploadPostAttachment(data.Bytes(), cfg.channelid, filename)
if err != nil {
return nil, false
}
_, err = field.Write([]byte(cfg.channelid))
if err != nil {
return nil, false
}
err = writer.Close()
if err != nil {
return nil, false
}
resp, appErr := cfg.client.UploadPostAttachment(body.Bytes(), writer.FormDataContentType())
if appErr != nil { if appErr != nil {
return nil, false return nil, false
} }
return resp.Data.(*model.FileUploadResponse).Filenames, true return []string{resp.FileInfos[0].Id}, true
} }
func (cfg *AutoPostCreator) CreateRandomPost() (*model.Post, bool) { func (cfg *AutoPostCreator) CreateRandomPost() (*model.Post, bool) {
var filenames []string var fileIds []string
if cfg.HasImage { if cfg.HasImage {
var err1 bool var err1 bool
filenames, err1 = cfg.UploadTestFile() fileIds, err1 = cfg.UploadTestFile()
if err1 == false { if err1 == false {
return nil, false return nil, false
} }
@@ -102,7 +79,7 @@ func (cfg *AutoPostCreator) CreateRandomPost() (*model.Post, bool) {
post := &model.Post{ post := &model.Post{
ChannelId: cfg.channelid, ChannelId: cfg.channelid,
Message: postText, Message: postText,
Filenames: filenames} FileIds: fileIds}
result, err2 := cfg.client.CreatePost(post) result, err2 := cfg.client.CreatePost(post)
if err2 != nil { if err2 != nil {
return nil, false return nil, false

Просмотреть файл

@@ -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]))
}
}
}

Просмотреть файл

@@ -16,6 +16,7 @@ import (
"io" "io"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
@@ -57,17 +58,19 @@ const (
MaxImageSize = 6048 * 4032 // 24 megapixels, roughly 36MB as a raw image MaxImageSize = 6048 * 4032 // 24 megapixels, roughly 36MB as a raw image
) )
var fileInfoCache *utils.Cache = utils.NewLru(1000)
func InitFile() { func InitFile() {
l4g.Debug(utils.T("api.file.init.debug")) l4g.Debug(utils.T("api.file.init.debug"))
BaseRoutes.Files.Handle("/upload", ApiUserRequired(uploadFile)).Methods("POST") BaseRoutes.TeamFiles.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.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.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/{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) { 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 return
} }
err := r.ParseMultipartForm(*utils.Cfg.FileSettings.MaxFileSize) if err := r.ParseMultipartForm(*utils.Cfg.FileSettings.MaxFileSize); err != nil {
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
@@ -92,7 +94,6 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) {
m := r.MultipartForm m := r.MultipartForm
props := m.Value props := m.Value
if len(props["channel_id"]) == 0 { if len(props["channel_id"]) == 0 {
c.SetInvalidParam("uploadFile", "channel_id") c.SetInvalidParam("uploadFile", "channel_id")
return return
@@ -103,91 +104,108 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) {
return return
} }
files := m.File["files"]
resStruct := &model.FileUploadResponse{
Filenames: []string{},
ClientIds: []string{},
}
imageNameList := []string{}
imageDataList := [][]byte{}
if !HasPermissionToChannelContext(c, channelId, model.PERMISSION_UPLOAD_FILE) { if !HasPermissionToChannelContext(c, channelId, model.PERMISSION_UPLOAD_FILE) {
return return
} }
for i := range files { resStruct := &model.FileUploadResponse{
file, err := files[i].Open() FileInfos: []*model.FileInfo{},
ClientIds: []string{},
}
previewPathList := []string{}
thumbnailPathList := []string{}
imageDataList := [][]byte{}
for i, fileHeader := range m.File["files"] {
file, fileErr := fileHeader.Open()
defer file.Close() defer file.Close()
if err != nil { if fileErr != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, fileErr.Error(), http.StatusInternalServerError)
return return
} }
buf := bytes.NewBuffer(nil) buf := bytes.NewBuffer(nil)
io.Copy(buf, file) 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 {
uid := model.NewId()
if model.IsFileExtImage(filepath.Ext(files[i].Filename)) {
imageNameList = append(imageNameList, uid+"/"+filename)
imageDataList = append(imageDataList, buf.Bytes())
// 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
}
}
path := "teams/" + c.TeamId + "/channels/" + channelId + "/users/" + c.Session.UserId + "/" + uid + "/" + filename
if err := WriteFile(buf.Bytes(), path); err != nil {
c.Err = err c.Err = err
return return
} }
encName := utils.UrlEncode(filename) if info.PreviewPath != "" || info.ThumbnailPath != "" {
previewPathList = append(previewPathList, info.PreviewPath)
thumbnailPathList = append(thumbnailPathList, info.ThumbnailPath)
imageDataList = append(imageDataList, data)
}
fileUrl := "/" + channelId + "/" + c.Session.UserId + "/" + uid + "/" + encName resStruct.FileInfos = append(resStruct.FileInfos, info)
resStruct.Filenames = append(resStruct.Filenames, fileUrl)
if len(m.Value["client_ids"]) > 0 {
resStruct.ClientIds = append(resStruct.ClientIds, m.Value["client_ids"][i])
}
} }
for _, clientId := range props["client_ids"] { handleImages(previewPathList, thumbnailPathList, imageDataList)
resStruct.ClientIds = append(resStruct.ClientIds, clientId)
}
go handleImages(imageNameList, imageDataList, c.TeamId, channelId, c.Session.UserId)
w.Write([]byte(resStruct.ToJson())) w.Write([]byte(resStruct.ToJson()))
} }
func handleImages(filenames []string, fileData [][]byte, teamId, channelId, userId string) { func doUploadFile(teamId string, channelId string, userId string, rawFilename string, data []byte) (*model.FileInfo, *model.AppError) {
dest := "teams/" + teamId + "/channels/" + channelId + "/users/" + userId + "/" filename := filepath.Base(rawFilename)
for i, filename := range filenames { info, err := model.GetInfoForBytes(filename, data)
name := filename[:strings.LastIndex(filename, ".")] if err != nil {
err.StatusCode = http.StatusBadRequest
return nil, err
}
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"
}
if err := WriteFile(data, info.Path); err != nil {
return nil, err
}
if result := <-Srv.Store.FileInfo().Save(info); result.Err != nil {
return nil, result.Err
}
return info, nil
}
func handleImages(previewPathList []string, thumbnailPathList []string, fileData [][]byte) {
for i := range fileData {
go func() { go func() {
// Decode image bytes into Image object // Decode image bytes into Image object
img, imgType, err := image.Decode(bytes.NewReader(fileData[i])) img, imgType, err := image.Decode(bytes.NewReader(fileData[i]))
if err != nil { 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 return
} }
width := img.Bounds().Dx() width := img.Bounds().Dx()
height := img.Bounds().Dy() height := img.Bounds().Dy()
// Get the image's orientation and ignore any errors since not all images will have orientation data // Fill in the background of a potentially-transparent png file as white
orientation, _ := getImageOrientation(fileData[i])
if imgType == "png" { if imgType == "png" {
dst := image.NewRGBA(img.Bounds()) dst := image.NewRGBA(img.Bounds())
draw.Draw(dst, dst.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) 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 img = dst
} }
// Flip the image to be upright
orientation, _ := getImageOrientation(fileData[i])
switch orientation { switch orientation {
case UprightMirrored: case UprightMirrored:
img = imaging.FlipH(img) img = imaging.FlipH(img)
@@ -212,57 +233,8 @@ func handleImages(filenames []string, fileData [][]byte, teamId, channelId, user
img = imaging.Rotate90(img) img = imaging.Rotate90(img)
} }
// Create thumbnail go generateThumbnailImage(img, thumbnailPathList[i], width, height)
go func() { go generatePreviewImage(img, previewPathList[i], width)
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
}
}()
}() }()
} }
} }
@@ -284,88 +256,214 @@ func getImageOrientation(imageData []byte) (int, error) {
} }
} }
type ImageGetResult struct { func generateThumbnailImage(img image.Image, thumbnailPath string, width int, height int) {
Error error thumbWidth := float64(utils.Cfg.FileSettings.ThumbnailWidth)
ImageData []byte 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)
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
}
if err := WriteFile(buf.Bytes(), thumbnailPath); err != nil {
l4g.Error(utils.T("api.file.handle_images_forget.upload_thumb.error"), thumbnailPath, err)
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
}
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 err := WriteFile(buf.Bytes(), previewPath); err != nil {
l4g.Error(utils.T("api.file.handle_images_forget.upload_preview.error"), previewPath, err)
return
}
}
func getFile(c *Context, w http.ResponseWriter, r *http.Request) {
info, err := getFileInfoForRequest(c, r, true)
if err != nil {
c.Err = err
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
}
}
func getFileThumbnail(c *Context, w http.ResponseWriter, r *http.Request) {
info, err := getFileInfoForRequest(c, r, true)
if err != nil {
c.Err = err
return
}
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
}
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 getFilePreview(c *Context, w http.ResponseWriter, r *http.Request) {
info, err := getFileInfoForRequest(c, r, true)
if err != nil {
c.Err = err
return
}
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 data, err := ReadFile(info.PreviewPath); 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 getFileInfo(c *Context, w http.ResponseWriter, r *http.Request) { func getFileInfo(c *Context, w http.ResponseWriter, r *http.Request) {
if len(utils.Cfg.FileSettings.DriverName) == 0 { info, err := getFileInfoForRequest(c, r, true)
c.Err = model.NewLocAppError("uploadFile", "api.file.upload_file.storage.app_error", nil, "") if err != nil {
c.Err.StatusCode = http.StatusNotImplemented c.Err = err
return return
} }
params := mux.Vars(r)
channelId := params["channel_id"]
if len(channelId) != 26 {
c.SetInvalidParam("getFileInfo", "channel_id")
return
}
userId := params["user_id"]
if len(userId) != 26 {
c.SetInvalidParam("getFileInfo", "user_id")
return
}
filename := params["filename"]
if len(filename) == 0 {
c.SetInvalidParam("getFileInfo", "filename")
return
}
if !HasPermissionToChannelContext(c, channelId, model.PERMISSION_READ_CHANNEL) {
return
}
path := "teams/" + c.TeamId + "/channels/" + channelId + "/users/" + userId + "/" + filename
var info *model.FileInfo
if cached, ok := fileInfoCache.Get(path); ok {
info = cached.(*model.FileInfo)
} else {
fileData := make(chan []byte)
go readFile(path, fileData)
newInfo, err := model.GetInfoForBytes(filename, <-fileData)
if err != nil {
c.Err = err
return
} else {
fileInfoCache.Add(path, newInfo)
info = newInfo
}
}
w.Header().Set("Cache-Control", "max-age=2592000, public") w.Header().Set("Cache-Control", "max-age=2592000, public")
w.Write([]byte(info.ToJson())) w.Write([]byte(info.ToJson()))
} }
func getFile(c *Context, w http.ResponseWriter, r *http.Request) { func getPublicFile(c *Context, w http.ResponseWriter, r *http.Request) {
params := mux.Vars(r) if !utils.Cfg.FileSettings.EnablePublicLink {
c.Err = model.NewLocAppError("getPublicFile", "api.file.get_file.public_disabled.app_error", nil, "")
teamId := c.TeamId c.Err.StatusCode = http.StatusNotImplemented
channelId := params["channel_id"]
userId := params["user_id"]
filename := params["filename"]
if !HasPermissionToChannelContext(c, channelId, model.PERMISSION_READ_CHANNEL) {
return return
} }
if err, bytes := getFileData(teamId, channelId, userId, filename); err != nil { info, err := getFileInfoForRequest(c, r, false)
if err != nil {
c.Err = err c.Err = err
return return
} else if err := writeFileResponse(filename, bytes, w, r); err != nil { }
hash := r.URL.Query().Get("h")
if len(hash) > 0 {
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, "")
c.Err.StatusCode = http.StatusBadRequest
return
}
} else {
c.Err = model.NewLocAppError("getPublicFile", "api.file.get_file.public_invalid.app_error", nil, "")
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 c.Err = err
return return
} }
} }
func getPublicFile(c *Context, w http.ResponseWriter, r *http.Request) { func getFileInfoForRequest(c *Context, r *http.Request, requireFileVisible bool) (*model.FileInfo, *model.AppError) {
if len(utils.Cfg.FileSettings.DriverName) == 0 {
err := model.NewLocAppError("getFileInfoForRequest", "api.file.get_file_info_for_request.storage.app_error", nil, "")
err.StatusCode = http.StatusNotImplemented
return nil, err
}
params := mux.Vars(r)
fileId := params["file_id"]
if len(fileId) != 26 {
return nil, NewInvalidParamError("getFileInfoForRequest", "file_id")
}
var info *model.FileInfo
if result := <-Srv.Store.FileInfo().Get(fileId); result.Err != nil {
return nil, result.Err
} else {
info = result.Data.(*model.FileInfo)
}
// 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
}
}
}
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
}
params := mux.Vars(r) params := mux.Vars(r)
teamId := params["team_id"] teamId := params["team_id"]
@@ -375,12 +473,6 @@ func getPublicFile(c *Context, w http.ResponseWriter, r *http.Request) {
hash := r.URL.Query().Get("h") hash := r.URL.Query().Get("h")
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
}
if len(hash) > 0 { if len(hash) > 0 {
correctHash := generatePublicLinkHash(filename, *utils.Cfg.FileSettings.PublicLinkSalt) correctHash := generatePublicLinkHash(filename, *utils.Cfg.FileSettings.PublicLinkSalt)
@@ -395,49 +487,28 @@ func getPublicFile(c *Context, w http.ResponseWriter, r *http.Request) {
return return
} }
if err, bytes := getFileData(teamId, channelId, userId, filename); err != nil {
c.Err = err
return
} else if err := writeFileResponse(filename, bytes, w, r); err != nil {
c.Err = err
return
}
}
func getFileData(teamId string, channelId string, userId string, filename string) (*model.AppError, []byte) {
if len(utils.Cfg.FileSettings.DriverName) == 0 {
err := model.NewLocAppError("getFileData", "api.file.upload_file.storage.app_error", nil, "")
err.StatusCode = http.StatusNotImplemented
return err, nil
}
if len(teamId) != 26 {
return NewInvalidParamError("getFileData", "team_id"), nil
}
if len(channelId) != 26 {
return NewInvalidParamError("getFileData", "channel_id"), nil
}
if len(userId) != 26 {
return NewInvalidParamError("getFileData", "user_id"), nil
}
if len(filename) == 0 {
return NewInvalidParamError("getFileData", "filename"), nil
}
path := "teams/" + teamId + "/channels/" + channelId + "/users/" + userId + "/" + filename path := "teams/" + teamId + "/channels/" + channelId + "/users/" + userId + "/" + filename
fileChan := make(chan []byte) var info *model.FileInfo
go readFile(path, fileChan) if result := <-Srv.Store.FileInfo().GetByPath(path); result.Err != nil {
c.Err = result.Err
if bytes := <-fileChan; bytes == nil { return
err := model.NewLocAppError("writeFileResponse", "api.file.get_file.not_found.app_error", nil, "path="+path)
err.StatusCode = http.StatusNotFound
return err, nil
} else { } else {
return nil, bytes 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()) ua := user_agent.New(r.UserAgent())
bname, _ := ua.Browser() bname, _ := ua.Browser()
parts := strings.Split(filename, "/") w.Header().Set("Content-Disposition", "attachment;filename=\""+filename+"\"")
filePart := strings.Split(parts[len(parts)-1], "?")[0]
w.Header().Set("Content-Disposition", "attachment;filename=\""+filePart+"\"")
if bname == "Edge" || bname == "Internet Explorer" || bname == "Safari" { if bname == "Edge" || bname == "Internet Explorer" || bname == "Safari" {
w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Type", "application/octet-stream")
@@ -467,71 +536,183 @@ func writeFileResponse(filename string, bytes []byte, w http.ResponseWriter, r *
return nil 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) { 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 { if !utils.Cfg.FileSettings.EnablePublicLink {
c.Err = model.NewLocAppError("getPublicLink", "api.file.get_public_link.disabled.app_error", nil, "") c.Err = model.NewLocAppError("getPublicLink", "api.file.get_public_link.disabled.app_error", nil, "")
c.Err.StatusCode = http.StatusNotImplemented c.Err.StatusCode = http.StatusNotImplemented
return return
} }
props := model.MapFromJson(r.Body) info, err := getFileInfoForRequest(c, r, true)
if err != nil {
filename := props["filename"] c.Err = err
if len(filename) == 0 {
c.SetInvalidParam("getPublicLink", "filename")
return return
} }
matches := model.PartialUrlRegex.FindAllStringSubmatch(filename, -1) if len(info.PostId) == 0 {
if len(matches) == 0 || len(matches[0]) < 4 { c.Err = model.NewLocAppError("getPublicLink", "api.file.get_public_link.no_post.app_error", nil, "file_id="+info.Id)
c.SetInvalidParam("getPublicLink", "filename") c.Err.StatusCode = http.StatusBadRequest
return return
} }
channelId := matches[0][1] w.Write([]byte(model.StringToJson(generatePublicLink(c.GetSiteURL(), info))))
userId := matches[0][2]
filename = matches[0][3]
if !HasPermissionToChannelContext(c, channelId, model.PERMISSION_GET_PUBLIC_LINK) {
return
}
url := generatePublicLink(c.GetSiteURL(), c.TeamId, channelId, userId, filename)
w.Write([]byte(model.StringToJson(url)))
} }
func generatePublicLink(siteURL, teamId, channelId, userId, filename string) string { func generatePublicLink(siteURL string, info *model.FileInfo) string {
hash := generatePublicLinkHash(filename, *utils.Cfg.FileSettings.PublicLinkSalt) hash := generatePublicLinkHash(info.Id, *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) return fmt.Sprintf("%s%s/public/files/%v/get?h=%s", siteURL, model.API_URL_SUFFIX, info.Id, hash)
} }
func generatePublicLinkHash(filename, salt string) string { func generatePublicLinkHash(fileId, salt string) string {
hash := sha256.New() hash := sha256.New()
hash.Write([]byte(salt)) hash.Write([]byte(salt))
hash.Write([]byte(filename)) hash.Write([]byte(fileId))
return base64.RawURLEncoding.EncodeToString(hash.Sum(nil)) return base64.RawURLEncoding.EncodeToString(hash.Sum(nil))
} }
func WriteFile(f []byte, path string) *model.AppError { // 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)
}
}
// Copy and save the updated post
newPost := &model.Post{}
*newPost = *post
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 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 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
}
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 { if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_S3 {
var auth aws.Auth var auth aws.Auth
auth.AccessKey = utils.Cfg.FileSettings.AmazonS3AccessKeyId 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()) return model.NewLocAppError("WriteFile", "api.file.write_file.s3.app_error", nil, err.Error())
} }
} else if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_LOCAL { } 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 return err
} }
} else { } else {
@@ -568,9 +749,7 @@ func WriteFile(f []byte, path string) *model.AppError {
func MoveFile(oldPath, newPath string) *model.AppError { func MoveFile(oldPath, newPath string) *model.AppError {
if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_S3 { if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_S3 {
fileData := make(chan []byte) fileBytes, _ := ReadFile(oldPath)
go readFile(oldPath, fileData)
fileBytes := <-fileData
if fileBytes == nil { if fileBytes == nil {
return model.NewLocAppError("moveFile", "api.file.move_file.get_from_s3.app_error", 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 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 { if err := os.MkdirAll(filepath.Dir(path), 0774); err != nil {
directory, _ := filepath.Abs(filepath.Dir(path)) 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()) 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) { func ReadFile(path string) ([]byte, *model.AppError) {
if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_S3 { if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_S3 {
var auth aws.Auth var auth aws.Auth
auth.AccessKey = utils.Cfg.FileSettings.AmazonS3AccessKeyId auth.AccessKey = utils.Cfg.FileSettings.AmazonS3AccessKeyId
@@ -668,7 +846,6 @@ func openFileWriteStream(path string) (io.Writer, *model.AppError) {
fileHandle.Chmod(0644) fileHandle.Chmod(0644)
return fileHandle, nil return fileHandle, nil
} }
} }
return nil, model.NewLocAppError("openFileWriteStream", "api.file.open_file_write_stream.configured.app_error", nil, "") return nil, model.NewLocAppError("openFileWriteStream", "api.file.open_file_write_stream.configured.app_error", nil, "")

Просмотреть файл

@@ -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)
}
}
}

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@@ -49,6 +49,7 @@ func InitPost() {
BaseRoutes.NeedPost.Handle("/delete", ApiUserRequired(deletePost)).Methods("POST") 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("/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("/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) { 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) 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 var rpost *model.Post
if result := <-Srv.Store.Post().Save(post); result.Err != nil { if result := <-Srv.Store.Post().Save(post); result.Err != nil {
return nil, result.Err return nil, result.Err
} else { } else {
rpost = result.Data.(*model.Post) rpost = result.Data.(*model.Post)
go handlePostEvents(c, rpost, triggerWebhooks)
} }
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)
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 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) pchan := Srv.Store.User().GetProfiles(c.TeamId)
dpchan := Srv.Store.User().GetDirectProfiles(c.Session.UserId) dpchan := Srv.Store.User().GetDirectProfiles(c.Session.UserId)
mchan := Srv.Store.Channel().GetMembers(post.ChannelId) mchan := Srv.Store.Channel().GetMembers(post.ChannelId)
fchan := Srv.Store.FileInfo().GetForPost(post.Id)
var profileMap map[string]*model.User var profileMap map[string]*model.User
if result := <-pchan; result.Err != nil { 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("sender_name", senderName)
message.Add("team_id", team.Id) message.Add("team_id", team.Id)
if len(post.Filenames) != 0 { if len(post.FileIds) != 0 {
message.Add("otherFile", "true") message.Add("otherFile", "true")
for _, filename := range post.Filenames { var infos []*model.FileInfo
ext := filepath.Ext(filename) if result := <-fchan; result.Err != nil {
if model.IsFileExtImage(ext) { 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") message.Add("image", "true")
break 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 { 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 return post.Message
} }
// extract the filenames from their paths and determine what type of files are attached // 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 onlyImages := true
for i, filename := range post.Filenames { for i, info := range infos {
var err error if escaped, err := url.QueryUnescape(filepath.Base(info.Name)); err != nil {
if filenames[i], err = url.QueryUnescape(filepath.Base(filename)); err != nil {
// this should never error since filepath was escaped using url.QueryEscape // 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 && info.IsImage()
onlyImages = onlyImages && model.IsFileExtImage(ext)
} }
props := map[string]interface{}{"Filenames": strings.Join(filenames, ", ")} props := map[string]interface{}{"Filenames": strings.Join(filenames, ", ")}
@@ -1099,9 +1092,6 @@ func SendEphemeralPost(teamId, userId string, post *model.Post) {
if post.Props == nil { if post.Props == nil {
post.Props = model.StringInterface{} post.Props = model.StringInterface{}
} }
if post.Filenames == nil {
post.Filenames = []string{}
}
message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_EPHEMERAL_MESSAGE, "", post.ChannelId, userId, nil) message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_EPHEMERAL_MESSAGE, "", post.ChannelId, userId, nil)
message.Add("post", post.ToJson()) 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
if result := <-Srv.Store.Post().Update(oldPost, post.Message, hashtags); result.Err != nil { newPost.Message = post.Message
newPost.Hashtags, _ = model.ParseHashtags(post.Message)
if result := <-Srv.Store.Post().Update(newPost, oldPost); result.Err != nil {
c.Err = result.Err c.Err = result.Err
return return
} else { } else {
@@ -1449,7 +1443,7 @@ func deletePost(c *Context, w http.ResponseWriter, r *http.Request) {
message.Add("post", post.ToJson()) message.Add("post", post.ToJson())
go Publish(message) go Publish(message)
go DeletePostFiles(c.TeamId, post) go DeletePostFiles(post)
go DeleteFlaggedPost(c.Session.UserId, post) go DeleteFlaggedPost(c.Session.UserId, post)
result := make(map[string]string) result := make(map[string]string)
@@ -1465,17 +1459,13 @@ func DeleteFlaggedPost(userId string, post *model.Post) {
} }
} }
func DeletePostFiles(teamId string, post *model.Post) { func DeletePostFiles(post *model.Post) {
if len(post.Filenames) == 0 { if len(post.FileIds) != 0 {
return return
} }
prefix := "teams/" + teamId + "/channels/" + post.ChannelId + "/users/" + post.UserId + "/" if result := <-Srv.Store.FileInfo().DeleteForPost(post.Id); result.Err != nil {
for _, filename := range post.Filenames { l4g.Warn(utils.T("api.post.delete_post_files.app_error.warn"), post.Id, result.Err)
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)
} }
} }
@@ -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.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Write([]byte(posts.ToJson())) 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)))
}
}

Просмотреть файл

@@ -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))
}
}
}

Просмотреть файл

@@ -15,6 +15,7 @@ import (
"time" "time"
"github.com/mattermost/platform/model" "github.com/mattermost/platform/model"
"github.com/mattermost/platform/store"
"github.com/mattermost/platform/utils" "github.com/mattermost/platform/utils"
) )
@@ -23,15 +24,12 @@ func TestCreatePost(t *testing.T) {
Client := th.BasicClient Client := th.BasicClient
team := th.BasicTeam team := th.BasicTeam
team2 := th.CreateTeam(th.BasicClient) team2 := th.CreateTeam(th.BasicClient)
user1 := th.BasicUser
user3 := th.CreateUser(th.BasicClient) user3 := th.CreateUser(th.BasicClient)
LinkUserToTeam(user3, team2) LinkUserToTeam(user3, team2)
channel1 := th.BasicChannel channel1 := th.BasicChannel
channel2 := th.CreateChannel(Client, team) 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"}
post1 := &model.Post{ChannelId: channel1.Id, Message: "#hashtag a" + model.NewId() + "a", Filenames: filenames}
rpost1, err := Client.CreatePost(post1) rpost1, err := Client.CreatePost(post1)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -45,8 +43,8 @@ func TestCreatePost(t *testing.T) {
t.Fatal("hashtag didn't match") t.Fatal("hashtag didn't match")
} }
if len(rpost1.Data.(*model.Post).Filenames) != 2 { if len(rpost1.Data.(*model.Post).FileIds) != 0 {
t.Fatal("filenames didn't parse correctly") t.Fatal("shouldn't have files")
} }
post2 := &model.Post{ChannelId: channel1.Id, Message: "a" + model.NewId() + "a", RootId: rpost1.Data.(*model.Post).Id} 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 { if _, err = Client.DoApiPost("/channels/"+channel3.Id+"/create", "garbage"); err == nil {
t.Fatal("should have been an error") 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( func testCreatePostWithOutgoingHook(
@@ -800,10 +827,8 @@ func TestFuzzyPosts(t *testing.T) {
Client := th.BasicClient Client := th.BasicClient
channel1 := th.BasicChannel channel1 := th.BasicChannel
filenames := []string{"junk"}
for i := 0; i < len(utils.FUZZY_STRINGS_POSTS); i++ { 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) _, err := Client.CreatePost(post)
if err != nil { if err != nil {
@@ -1150,19 +1175,49 @@ func TestGetFlaggedPosts(t *testing.T) {
} }
func TestGetMessageForNotification(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") translateFunc := utils.GetUserTranslations("en")
post := &model.Post{ post := &model.Post{
Message: "test", Id: model.NewId(),
Filenames: model.StringArray{}, Message: "test",
} }
if getMessageForNotification(post, translateFunc) != "test" { if getMessageForNotification(post, translateFunc) != "test" {
t.Fatal("should've returned message text") 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" { if getMessageForNotification(post, translateFunc) != "test" {
t.Fatal("should've returned message text, even with attachments") 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) 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" { if message := getMessageForNotification(post, translateFunc); message != "2 images sent: test1.png, test2.jpg" {
t.Fatal("should've returned number of images:", message) 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" { if message := getMessageForNotification(post, translateFunc); message != "1 file sent: test1.go" {
t.Fatal("should've returned number of files:", message) t.Fatal("should've returned number of files:", message)
} }
post.Filenames = model.StringArray{"test1.go", "test2.jpg"} store.Must(Srv.Store.FileInfo().AttachToPost(testJpg2.Id, post.Id))
if message := getMessageForNotification(post, translateFunc); message != "2 files sent: test1.go, test2.jpg" { 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) 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")
}
}

Просмотреть файл

@@ -829,14 +829,6 @@
"id": "api.emoji.upload.large_image.app_error", "id": "api.emoji.upload.large_image.app_error",
"translation": "Unable to create emoji. Image must be at most 128 by 128 pixels." "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", "id": "api.file.get_file.public_disabled.app_error",
"translation": "Public links have been disabled by the system administrator" "translation": "Public links have been disabled by the system administrator"
@@ -849,30 +841,98 @@
"id": "api.file.get_public_link.disabled.app_error", "id": "api.file.get_public_link.disabled.app_error",
"translation": "Public links have been disabled" "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", "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", "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", "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", "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", "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", "id": "api.file.init.debug",
"translation": "Initializing file api routes" "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", "id": "api.file.move_file.configured.app_error",
"translation": "File storage not configured properly. Please configure for either S3 or local server file storage." "translation": "File storage not configured properly. Please configure for either S3 or local server file storage."
@@ -917,22 +977,6 @@
"id": "api.file.read_file.reading_local.app_error", "id": "api.file.read_file.reading_local.app_error",
"translation": "Encountered an error reading from local server storage" "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", "id": "api.file.write_file.configured.app_error",
"translation": "File storage not configured properly. Please configure for either S3 or local server file storage." "translation": "File storage not configured properly. Please configure for either S3 or local server file storage."
@@ -949,6 +993,18 @@
"id": "api.file.write_file_locally.writing.app_error", "id": "api.file.write_file_locally.writing.app_error",
"translation": "Encountered an error writing to local server storage" "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", "id": "api.general.init.debug",
"translation": "Initializing general api routes" "translation": "Initializing general api routes"
@@ -1161,6 +1217,10 @@
"id": "api.post.check_for_out_of_channel_mentions.message.one", "id": "api.post.check_for_out_of_channel_mentions.message.one",
"translation": "{{.Username}} was mentioned, but they did not receive a notification because they do not belong to this channel." "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", "id": "api.post.create_post.bad_filename.error",
"translation": "Bad filename discarded, filename=%v" "translation": "Bad filename discarded, filename=%v"
@@ -1197,6 +1257,14 @@
"id": "api.post.delete_post.permissions.app_error", "id": "api.post.delete_post.permissions.app_error",
"translation": "You do not have the appropriate permissions" "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", "id": "api.post.get_message_for_notification.files_sent",
"translation": { "translation": {
@@ -1287,6 +1355,10 @@
"id": "api.post.send_notifications_and_forget.comment_thread.error", "id": "api.post.send_notifications_and_forget.comment_thread.error",
"translation": "Failed to retrieve comment thread posts in notifications root_post_id=%v, err=%v" "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", "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" "translation": "Failed to get teams when sending cross-team DM user_id=%v, err=%v"
@@ -3423,6 +3495,10 @@
"id": "model.post.is_valid.filenames.app_error", "id": "model.post.is_valid.filenames.app_error",
"translation": "Invalid filenames" "translation": "Invalid filenames"
}, },
{
"id": "model.post.is_valid.file_ids.app_error",
"translation": "Invalid file ids"
},
{ {
"id": "model.post.is_valid.hashtags.app_error", "id": "model.post.is_valid.hashtags.app_error",
"translation": "Invalid hashtags" "translation": "Invalid hashtags"
@@ -3875,6 +3951,10 @@
"id": "store.sql_channel.get_extra_members.app_error", "id": "store.sql_channel.get_extra_members.app_error",
"translation": "We couldn't get the extra info for channel members" "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", "id": "store.sql_channel.get_member.app_error",
"translation": "We couldn't get the channel member" "translation": "We couldn't get the channel member"
@@ -3887,6 +3967,10 @@
"id": "store.sql_channel.get_member_count.app_error", "id": "store.sql_channel.get_member_count.app_error",
"translation": "We couldn't get the channel member count" "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", "id": "store.sql_channel.get_members.app_error",
"translation": "We couldn't get the channel members" "translation": "We couldn't get the channel members"
@@ -4071,6 +4155,30 @@
"id": "store.sql_emoji.save.app_error", "id": "store.sql_emoji.save.app_error",
"translation": "We couldn't save the emoji" "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", "id": "store.sql_license.get.app_error",
"translation": "We encountered an error getting the license" "translation": "We encountered an error getting the license"

Просмотреть файл

@@ -124,6 +124,10 @@ func (c *Client) GetGeneralRoute() string {
return "/general" 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) { func (c *Client) DoPost(url, data, contentType string) (*http.Response, *AppError) {
rq, _ := http.NewRequest("POST", c.Url+url, strings.NewReader(data)) rq, _ := http.NewRequest("POST", c.Url+url, strings.NewReader(data))
rq.Header.Set("Content-Type", contentType) 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) return c.uploadFile(c.ApiUrl+"/users/newimage", data, contentType)
} }
func (c *Client) UploadPostAttachment(data []byte, contentType string) (*Result, *AppError) { func (c *Client) UploadPostAttachment(data []byte, channelId string, filename string) (*FileUploadResponse, *AppError) {
return c.uploadFile(c.ApiUrl+c.GetTeamRoute()+"/files/upload", data, contentType) 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) { 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) { func (c *Client) GetFile(fileId string) (io.ReadCloser, *AppError) {
var rq *http.Request if r, err := c.DoApiGet(c.GetFileRoute(fileId)+"/get", "", ""); err != nil {
if isFullUrl { return nil, err
rq, _ = http.NewRequest("GET", url, nil)
} else { } else {
rq, _ = http.NewRequest("GET", c.ApiUrl+c.GetTeamRoute()+"/files/get"+url, nil) c.fillInExtraProperties(r)
} return r.Body, nil
if len(c.AuthToken) > 0 {
rq.Header.Set(HEADER_AUTH, "BEARER "+c.AuthToken)
}
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)
} else {
defer closeBody(rp)
return &Result{rp.Header.Get(HEADER_REQUEST_ID),
rp.Header.Get(HEADER_ETAG_SERVER), rp.Body}, nil
} }
} }
func (c *Client) GetFileInfo(url string) (*Result, *AppError) { func (c *Client) GetFileThumbnail(fileId string) (io.ReadCloser, *AppError) {
var rq *http.Request if r, err := c.DoApiGet(c.GetFileRoute(fileId)+"/get_thumbnail", "", ""); err != nil {
rq, _ = http.NewRequest("GET", c.ApiUrl+c.GetTeamRoute()+"/files/get_info"+url, nil) return nil, err
if len(c.AuthToken) > 0 {
rq.Header.Set(HEADER_AUTH, "BEARER "+c.AuthToken)
}
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)
} else { } else {
defer closeBody(rp) c.fillInExtraProperties(r)
return &Result{rp.Header.Get(HEADER_REQUEST_ID), return r.Body, nil
rp.Header.Get(HEADER_ETAG_SERVER), FileInfoFromJson(rp.Body)}, nil
} }
} }
func (c *Client) GetPublicLink(filename string) (*Result, *AppError) { func (c *Client) GetFilePreview(fileId string) (io.ReadCloser, *AppError) {
if r, err := c.DoApiPost(c.GetTeamRoute()+"/files/get_public_link", MapToJson(map[string]string{"filename": filename})); err != nil { if r, err := c.DoApiGet(c.GetFileRoute(fileId)+"/get_preview", "", ""); err != nil {
return nil, err return nil, err
} else { } else {
defer closeBody(r) defer closeBody(r)
return &Result{r.Header.Get(HEADER_REQUEST_ID), c.fillInExtraProperties(r)
r.Header.Get(HEADER_ETAG_SERVER), StringFromJson(r.Body)}, nil return r.Body, nil
}
}
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(r)
c.fillInExtraProperties(r)
return FileInfoFromJson(r.Body), nil
}
}
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)
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 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
}
}

Просмотреть файл

@@ -34,7 +34,7 @@ type CompliancePost struct {
PostType string PostType string
PostProps string PostProps string
PostHashtags string PostHashtags string
PostFilenames string PostFileIds string
} }
func CompliancePostHeader() []string { func CompliancePostHeader() []string {
@@ -60,7 +60,7 @@ func CompliancePostHeader() []string {
"PostType", "PostType",
"PostProps", "PostProps",
"PostHashtags", "PostHashtags",
"PostFilenames", "PostFileIds",
} }
} }
@@ -99,6 +99,6 @@ func (me *CompliancePost) Row() []string {
me.PostType, me.PostType,
me.PostProps, me.PostProps,
me.PostHashtags, me.PostHashtags,
me.PostFilenames, me.PostFileIds,
} }
} }

Просмотреть файл

@@ -14,7 +14,7 @@ func TestCompliancePostHeader(t *testing.T) {
} }
func TestCompliancePost(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() r := o.Row()
if r[0] != "test" { if r[0] != "test" {

Просмотреть файл

@@ -14,8 +14,8 @@ var (
) )
type FileUploadResponse struct { type FileUploadResponse struct {
Filenames []string `json:"filenames"` FileInfos []*FileInfo `json:"file_infos"`
ClientIds []string `json:"client_ids"` ClientIds []string `json:"client_ids"`
} }
func FileUploadResponseFromJson(data io.Reader) *FileUploadResponse { func FileUploadResponseFromJson(data io.Reader) *FileUploadResponse {

Просмотреть файл

@@ -6,54 +6,31 @@ package model
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"image"
"image/gif" "image/gif"
"io" "io"
"mime" "mime"
"path/filepath" "path/filepath"
"strings"
) )
type FileInfo struct { type FileInfo struct {
Filename string `json:"filename"` Id string `json:"id"`
Size int `json:"size"` 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"` Extension string `json:"extension"`
Size int64 `json:"size"`
MimeType string `json:"mime_type"` 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)
} else {
mimeType = mime.TypeByExtension(extension)
}
if extension != "" && extension[0] == '.' {
// the client expects a file extension without the leading period
extension = extension[1:]
}
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
}
}
return &FileInfo{
Filename: filename,
Size: size,
Extension: extension,
MimeType: mimeType,
HasPreviewImage: hasPreviewImage,
}, nil
} }
func (info *FileInfo) ToJson() string { func (info *FileInfo) ToJson() string {
@@ -75,3 +52,123 @@ func FileInfoFromJson(data io.Reader) *FileInfo {
return &info return &info
} }
} }
func FileInfosToJson(infos []*FileInfo) string {
b, err := json.Marshal(infos)
if err != nil {
return ""
} else {
return string(b)
}
}
func FileInfosFromJson(data io.Reader) []*FileInfo {
decoder := json.NewDecoder(data)
var infos []*FileInfo
if err := decoder.Decode(&infos); err != nil {
return nil
} else {
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)
}

Просмотреть файл

@@ -5,56 +5,137 @@ package model
import ( import (
"encoding/base64" "encoding/base64"
_ "image/gif"
_ "image/png"
"io/ioutil" "io/ioutil"
"strings" "strings"
"testing" "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) fakeFile := make([]byte, 1000)
if info, err := GetInfoForBytes("file.txt", fakeFile); err != nil { if info, err := GetInfoForBytes("file.txt", fakeFile); err != nil {
t.Fatal(err) t.Fatal(err)
} else if info.Filename != "file.txt" { } else if info.Name != "file.txt" {
t.Fatalf("Got incorrect filename: %v", info.Filename) 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 { } else if info.Size != 1000 {
t.Fatalf("Got incorrect size: %v", info.Size) 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") { } else if !strings.HasPrefix(info.MimeType, "text/plain") {
t.Fatalf("Got incorrect mime type: %v", 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 { } 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) t.Fatal(err)
} else if info.Filename != "file.png" { } else if info.Name != "test.png" {
t.Fatalf("Got incorrect filename: %v", info.Filename) t.Fatalf("Got incorrect filename: %v", info.Name)
} else if info.Size != 1000 {
t.Fatalf("Got incorrect size: %v", info.Size)
} else if info.Extension != "png" { } 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" { } else if info.MimeType != "image/png" {
t.Fatalf("Got incorrect mime type: %v", info.MimeType) 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 { } 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 // base 64 encoded version of handtinywhite.gif from http://probablyprogramming.com/2009/03/15/the-tiniest-gif-ever
gifFile, _ := base64.StdEncoding.DecodeString("R0lGODlhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=") gifFile, _ := base64.StdEncoding.DecodeString("R0lGODlhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=")
if info, err := GetInfoForBytes("handtinywhite.gif", gifFile); err != nil { if info, err := GetInfoForBytes("handtinywhite.gif", gifFile); err != nil {
t.Fatal(err) t.Fatal(err)
} else if info.Filename != "handtinywhite.gif" { } else if info.Name != "handtinywhite.gif" {
t.Fatalf("Got incorrect filename: %v", info.Filename) 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 { } else if info.Size != 35 {
t.Fatalf("Got incorrect size: %v", info.Size) 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" { } else if info.MimeType != "image/gif" {
t.Fatalf("Got incorrect mime type: %v", info.MimeType) 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 { } 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") 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 { if info, err := GetInfoForBytes("testgif.gif", animatedGifFile); err != nil {
t.Fatal(err) t.Fatal(err)
} else if info.Filename != "testgif.gif" { } else if info.Name != "testgif.gif" {
t.Fatalf("Got incorrect filename: %v", info.Filename) 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 { } else if info.Size != 38689 {
t.Fatalf("Got incorrect size: %v", info.Size) 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" { } else if info.MimeType != "image/gif" {
t.Fatalf("Got incorrect mime type: %v", info.MimeType) 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 { } 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 { if info, err := GetInfoForBytes("filewithoutextension", fakeFile); err != nil {
t.Fatal(err) t.Fatal(err)
} else if info.Filename != "filewithoutextension" { } else if info.Name != "filewithoutextension" {
t.Fatalf("Got incorrect filename: %v", info.Filename) 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 { } else if info.Size != 1000 {
t.Fatalf("Got incorrect size: %v", info.Size) 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 != "" { } else if info.MimeType != "" {
t.Fatalf("Got incorrect mime type: %v", 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 { } 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)
} }
} }

Просмотреть файл

@@ -35,7 +35,8 @@ type Post struct {
Type string `json:"type"` Type string `json:"type"`
Props StringInterface `json:"props"` Props StringInterface `json:"props"`
Hashtags string `json:"hashtags"` 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:"-"` 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) 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 { if utf8.RuneCountInString(StringInterfaceToJson(o.Props)) > 8000 {
return NewLocAppError("Post.IsValid", "model.post.is_valid.props.app_error", nil, "id="+o.Id) 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 { if o.Filenames == nil {
o.Filenames = []string{} o.Filenames = []string{}
} }
if o.FileIds == nil {
o.FileIds = []string{}
}
} }
func (o *Post) MakeNonNil() { func (o *Post) MakeNonNil() {
if o.Props == nil { if o.Props == nil {
o.Props = make(map[string]interface{}) o.Props = make(map[string]interface{})
} }
if o.Filenames == nil {
o.Filenames = []string{}
}
} }
func (o *Post) AddProp(key string, value interface{}) { func (o *Post) AddProp(key string, value interface{}) {

Просмотреть файл

@@ -596,6 +596,36 @@ func (s SqlChannelStore) GetMember(channelId string, userId string) StoreChannel
return 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 { func (s SqlChannelStore) GetMemberCount(channelId string) StoreChannel {
storeChannel := make(StoreChannel, 1) storeChannel := make(StoreChannel, 1)
@@ -878,6 +908,35 @@ func (s SqlChannelStore) GetAll(teamId string) StoreChannel {
return 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 { func (s SqlChannelStore) AnalyticsTypeCount(teamId string, channelType string) StoreChannel {
storeChannel := make(StoreChannel, 1) storeChannel := make(StoreChannel, 1)

Просмотреть файл

@@ -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) { func TestChannelStoreDelete(t *testing.T) {
Setup() 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) { func TestGetMemberCount(t *testing.T) {
Setup() Setup()

Просмотреть файл

@@ -199,7 +199,7 @@ func (s SqlComplianceStore) ComplianceExport(job *model.Compliance) StoreChannel
Posts.Type AS PostType, Posts.Type AS PostType,
Posts.Props AS PostProps, Posts.Props AS PostProps,
Posts.Hashtags AS PostHashtags, Posts.Hashtags AS PostHashtags,
Posts.Filenames AS PostFilenames Posts.FileIds AS PostFileIds
FROM FROM
Teams, Teams,
Channels, Channels,

197
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
}

208
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")
}
}

Просмотреть файл

@@ -32,6 +32,7 @@ func NewSqlPostStore(sqlStore *SqlStore) PostStore {
table.ColMap("Hashtags").SetMaxSize(1000) table.ColMap("Hashtags").SetMaxSize(1000)
table.ColMap("Props").SetMaxSize(8000) table.ColMap("Props").SetMaxSize(8000)
table.ColMap("Filenames").SetMaxSize(4000) table.ColMap("Filenames").SetMaxSize(4000)
table.ColMap("FileIds").SetMaxSize(150)
} }
return s return s
@@ -94,42 +95,39 @@ func (s SqlPostStore) Save(post *model.Post) StoreChannel {
return 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) storeChannel := make(StoreChannel, 1)
go func() { go func() {
result := StoreResult{} result := StoreResult{}
editPost := *oldPost newPost.UpdateAt = model.GetMillis()
editPost.Message = newMessage
editPost.UpdateAt = model.GetMillis()
editPost.Hashtags = newHashtags
oldPost.DeleteAt = editPost.UpdateAt oldPost.DeleteAt = newPost.UpdateAt
oldPost.UpdateAt = editPost.UpdateAt oldPost.UpdateAt = newPost.UpdateAt
oldPost.OriginalId = oldPost.Id oldPost.OriginalId = oldPost.Id
oldPost.Id = model.NewId() oldPost.Id = model.NewId()
if result.Err = editPost.IsValid(); result.Err != nil { if result.Err = newPost.IsValid(); result.Err != nil {
storeChannel <- result storeChannel <- result
close(storeChannel) close(storeChannel)
return return
} }
if _, err := s.GetMaster().Update(&editPost); err != nil { if _, err := s.GetMaster().Update(newPost); err != nil {
result.Err = model.NewLocAppError("SqlPostStore.Update", "store.sql_post.update.app_error", nil, "id="+editPost.Id+", "+err.Error()) result.Err = model.NewLocAppError("SqlPostStore.Update", "store.sql_post.update.app_error", nil, "id="+newPost.Id+", "+err.Error())
} else { } else {
time := model.GetMillis() 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 { if len(newPost.RootId) > 0 {
s.GetMaster().Exec("UPDATE Posts SET UpdateAt = :UpdateAt WHERE Id = :RootId", map[string]interface{}{"UpdateAt": time, "RootId": editPost.RootId}) 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 // mark the old post as deleted
s.GetMaster().Insert(oldPost) s.GetMaster().Insert(oldPost)
result.Data = &editPost result.Data = newPost
} }
storeChannel <- result storeChannel <- result
@@ -972,7 +970,7 @@ func (s SqlPostStore) AnalyticsPostCount(teamId string, mustHaveFile bool, mustH
} }
if mustHaveFile { if mustHaveFile {
query += " AND Posts.Filenames != '[]'" query += " AND (Posts.FileIds != '[]' OR Posts.Filenames != '[]')"
} }
if mustHaveHashtag { if mustHaveHashtag {

Просмотреть файл

@@ -87,45 +87,73 @@ func TestPostStoreUpdate(t *testing.T) {
ro1 := (<-store.Post().Get(o1.Id)).Data.(*model.PostList).Posts[o1.Id] ro1 := (<-store.Post().Get(o1.Id)).Data.(*model.PostList).Posts[o1.Id]
ro2 := (<-store.Post().Get(o1.Id)).Data.(*model.PostList).Posts[o2.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 { if ro1.Message != o1.Message {
t.Fatal("Failed to save/get") t.Fatal("Failed to save/get")
} }
msg := o1.Message + "BBBBBBBBBB" o1a := &model.Post{}
if result := <-store.Post().Update(ro1, msg, ""); result.Err != nil { *o1a = *ro1
o1a.Message = ro1.Message + "BBBBBBBBBB"
if result := <-store.Post().Update(o1a, ro1); result.Err != nil {
t.Fatal(result.Err) t.Fatal(result.Err)
} }
msg2 := o2.Message + "DDDDDDD" ro1a := (<-store.Post().Get(o1.Id)).Data.(*model.PostList).Posts[o1.Id]
if result := <-store.Post().Update(ro2, msg2, ""); result.Err != nil {
if ro1a.Message != o1a.Message {
t.Fatal("Failed to update/get")
}
o2a := &model.Post{}
*o2a = *ro2
o2a.Message = ro2.Message + "DDDDDDD"
if result := <-store.Post().Update(o2a, ro2); result.Err != nil {
t.Fatal(result.Err) t.Fatal(result.Err)
} }
msg3 := o3.Message + "WWWWWWW" ro2a := (<-store.Post().Get(o1.Id)).Data.(*model.PostList).Posts[o2.Id]
if result := <-store.Post().Update(ro6, msg3, "#hashtag"); result.Err != nil {
if ro2a.Message != o2a.Message {
t.Fatal("Failed to update/get")
}
o3a := &model.Post{}
*o3a = *ro3
o3a.Message = ro3.Message + "WWWWWWW"
if result := <-store.Post().Update(o3a, ro3); result.Err != nil {
t.Fatal(result.Err) t.Fatal(result.Err)
} }
ro3 := (<-store.Post().Get(o1.Id)).Data.(*model.PostList).Posts[o1.Id] ro3a := (<-store.Post().Get(o3.Id)).Data.(*model.PostList).Posts[o3.Id]
if ro3.Message != msg { if ro3a.Message != o3a.Message && ro3a.Hashtags != o3a.Hashtags {
t.Fatal("Failed to update/get") t.Fatal("Failed to update/get")
} }
ro4 := (<-store.Post().Get(o1.Id)).Data.(*model.PostList).Posts[o2.Id] o4 := Must(store.Post().Save(&model.Post{
ChannelId: model.NewId(),
UserId: model.NewId(),
Message: model.NewId(),
Filenames: []string{"test"},
})).(*model.Post)
if ro4.Message != msg2 { ro4 := (<-store.Post().Get(o4.Id)).Data.(*model.PostList).Posts[o4.Id]
t.Fatal("Failed to update/get")
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)
} }
ro5 := (<-store.Post().Get(o3.Id)).Data.(*model.PostList).Posts[o3.Id] if ro4a := Must(store.Post().Get(o4.Id)).(*model.PostList).Posts[o4.Id]; len(ro4a.Filenames) != 0 {
t.Fatal("Failed to clear Filenames")
if ro5.Message != msg3 && ro5.Hashtags != "#hashtag" { } else if len(ro4a.FileIds) != 1 {
t.Fatal("Failed to update/get") t.Fatal("Failed to set FileIds")
} }
} }
func TestPostStoreDelete(t *testing.T) { func TestPostStoreDelete(t *testing.T) {

Просмотреть файл

@@ -81,6 +81,7 @@ type SqlStore struct {
recovery PasswordRecoveryStore recovery PasswordRecoveryStore
emoji EmojiStore emoji EmojiStore
status StatusStore status StatusStore
fileInfo FileInfoStore
SchemaVersion string SchemaVersion string
} }
@@ -129,6 +130,7 @@ func NewSqlStore() Store {
sqlStore.recovery = NewSqlPasswordRecoveryStore(sqlStore) sqlStore.recovery = NewSqlPasswordRecoveryStore(sqlStore)
sqlStore.emoji = NewSqlEmojiStore(sqlStore) sqlStore.emoji = NewSqlEmojiStore(sqlStore)
sqlStore.status = NewSqlStatusStore(sqlStore) sqlStore.status = NewSqlStatusStore(sqlStore)
sqlStore.fileInfo = NewSqlFileInfoStore(sqlStore)
err := sqlStore.master.CreateTablesIfNotExists() err := sqlStore.master.CreateTablesIfNotExists()
if err != nil { if err != nil {
@@ -155,6 +157,7 @@ func NewSqlStore() Store {
sqlStore.recovery.(*SqlPasswordRecoveryStore).CreateIndexesIfNotExists() sqlStore.recovery.(*SqlPasswordRecoveryStore).CreateIndexesIfNotExists()
sqlStore.emoji.(*SqlEmojiStore).CreateIndexesIfNotExists() sqlStore.emoji.(*SqlEmojiStore).CreateIndexesIfNotExists()
sqlStore.status.(*SqlStatusStore).CreateIndexesIfNotExists() sqlStore.status.(*SqlStatusStore).CreateIndexesIfNotExists()
sqlStore.fileInfo.(*SqlFileInfoStore).CreateIndexesIfNotExists()
sqlStore.preference.(*SqlPreferenceStore).DeleteUnusedFeatures() sqlStore.preference.(*SqlPreferenceStore).DeleteUnusedFeatures()
@@ -643,6 +646,10 @@ func (ss SqlStore) Status() StatusStore {
return ss.status return ss.status
} }
func (ss SqlStore) FileInfo() FileInfoStore {
return ss.fileInfo
}
func (ss SqlStore) DropAllTables() { func (ss SqlStore) DropAllTables() {
ss.master.TruncateTables() ss.master.TruncateTables()
} }

Просмотреть файл

@@ -181,7 +181,6 @@ func UpgradeDatabaseToVersion33(sqlStore *SqlStore) {
func UpgradeDatabaseToVersion34(sqlStore *SqlStore) { func UpgradeDatabaseToVersion34(sqlStore *SqlStore) {
if shouldPerformUpgrade(sqlStore, VERSION_3_3_0, VERSION_3_4_0) { if shouldPerformUpgrade(sqlStore, VERSION_3_3_0, VERSION_3_4_0) {
sqlStore.CreateColumnIfNotExists("Status", "Manual", "BOOLEAN", "BOOLEAN", "0") sqlStore.CreateColumnIfNotExists("Status", "Manual", "BOOLEAN", "BOOLEAN", "0")
sqlStore.CreateColumnIfNotExists("Status", "ActiveChannel", "varchar(26)", "varchar(26)", "") 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' WHERE Roles = ''")
sqlStore.GetMaster().Exec("UPDATE ChannelMembers SET Roles = 'channel_user channel_admin' WHERE Roles = 'admin'") 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 // UNCOMMENT WHEN WE DO RELEASE
// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!! // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

Просмотреть файл

@@ -45,6 +45,7 @@ type Store interface {
PasswordRecovery() PasswordRecoveryStore PasswordRecovery() PasswordRecoveryStore
Emoji() EmojiStore Emoji() EmojiStore
Status() StatusStore Status() StatusStore
FileInfo() FileInfoStore
MarkSystemRanUnitTests() MarkSystemRanUnitTests()
Close() Close()
DropAllTables() DropAllTables()
@@ -86,11 +87,13 @@ type ChannelStore interface {
GetMoreChannels(teamId string, userId string) StoreChannel GetMoreChannels(teamId string, userId string) StoreChannel
GetChannelCounts(teamId string, userId string) StoreChannel GetChannelCounts(teamId string, userId string) StoreChannel
GetAll(teamId string) StoreChannel GetAll(teamId string) StoreChannel
GetForPost(postId string) StoreChannel
SaveMember(member *model.ChannelMember) StoreChannel SaveMember(member *model.ChannelMember) StoreChannel
UpdateMember(member *model.ChannelMember) StoreChannel UpdateMember(member *model.ChannelMember) StoreChannel
GetMembers(channelId string) StoreChannel GetMembers(channelId string) StoreChannel
GetMember(channelId string, userId string) StoreChannel GetMember(channelId string, userId string) StoreChannel
GetMemberForPost(postId string, userId string) StoreChannel
GetMemberCount(channelId string) StoreChannel GetMemberCount(channelId string) StoreChannel
RemoveMember(channelId string, userId string) StoreChannel RemoveMember(channelId string, userId string) StoreChannel
PermanentDeleteMembersByUser(userId string) StoreChannel PermanentDeleteMembersByUser(userId string) StoreChannel
@@ -104,7 +107,7 @@ type ChannelStore interface {
type PostStore interface { type PostStore interface {
Save(post *model.Post) StoreChannel 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 Get(id string) StoreChannel
Delete(postId string, time int64) StoreChannel Delete(postId string, time int64) StoreChannel
PermanentDeleteByUser(userId string) StoreChannel PermanentDeleteByUser(userId string) StoreChannel
@@ -277,3 +280,12 @@ type StatusStore interface {
GetTotalActiveUsersCount() StoreChannel GetTotalActiveUsersCount() StoreChannel
UpdateLastActivityAt(userId string, lastActivityAt int64) 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
}

Просмотреть файл

@@ -34,3 +34,17 @@ func FileExistsInConfigFolder(filename string) bool {
} }
return false 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
}

Просмотреть файл

@@ -28,3 +28,19 @@ func TestStringArrayIntersection(t *testing.T) {
t.Fatal("should be 1") 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")
}
}

Просмотреть файл

@@ -294,11 +294,11 @@ export function showGetPostLinkModal(post) {
}); });
} }
export function showGetPublicLinkModal(filename) { export function showGetPublicLinkModal(fileId) {
AppDispatcher.handleViewAction({ AppDispatcher.handleViewAction({
type: ActionTypes.TOGGLE_GET_PUBLIC_LINK_MODAL, type: ActionTypes.TOGGLE_GET_PUBLIC_LINK_MODAL,
value: true, value: true,
filename fileId
}); });
} }
@@ -388,7 +388,6 @@ export function sendEphemeralPost(message, channelId) {
type: Constants.POST_TYPE_EPHEMERAL, type: Constants.POST_TYPE_EPHEMERAL,
create_at: timestamp, create_at: timestamp,
update_at: timestamp, update_at: timestamp,
filenames: [],
props: {} props: {}
}; };

Просмотреть файл

@@ -113,10 +113,14 @@ export default class Client {
return `${this.url}${this.urlVersion}/users`; return `${this.url}${this.urlVersion}/users`;
} }
getFilesRoute() { getTeamFilesRoute() {
return `${this.url}${this.urlVersion}/teams/${this.getTeamId()}/files`; return `${this.url}${this.urlVersion}/teams/${this.getTeamId()}/files`;
} }
getFileRoute(fileId) {
return `${this.url}${this.urlVersion}/files/${fileId}`;
}
getOAuthRoute() { getOAuthRoute() {
return `${this.url}${this.urlVersion}/oauth`; return `${this.url}${this.urlVersion}/oauth`;
} }
@@ -1520,34 +1524,20 @@ export default class Client {
end(this.handleResponse.bind(this, 'getFlaggedPosts', success, error)); 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 // Routes for Files
getFileInfo(filename, success, error) {
request.
get(`${this.getFilesRoute()}/get_info${filename}`).
set(this.defaultHeaders).
type('application/json').
accept('application/json').
end(this.handleResponse.bind(this, 'getFileInfo', success, error));
}
getPublicLink(filename, success, error) {
const data = {
filename
};
request.
post(`${this.getFilesRoute()}/get_public_link`).
set(this.defaultHeaders).
type('application/json').
accept('application/json').
send(data).
end(this.handleResponse.bind(this, 'getPublicLink', success, error));
}
uploadFile(file, filename, channelId, clientId, success, error) { uploadFile(file, filename, channelId, clientId, success, error) {
return request. return request.
post(`${this.getFilesRoute()}/upload`). post(`${this.getTeamFilesRoute()}/upload`).
set(this.defaultHeaders). set(this.defaultHeaders).
attach('files', file, filename). attach('files', file, filename).
field('channel_id', channelId). field('channel_id', channelId).
@@ -1556,6 +1546,51 @@ export default class Client {
end(this.handleResponse.bind(this, 'uploadFile', success, error)); end(this.handleResponse.bind(this, 'uploadFile', success, error));
} }
getFile(fileId, success, error) {
request.
get(`${this.getFileRoute(fileId)}/get`).
set(this.defaultHeaders).
type('application/json').
accept('application/json').
end(this.handleResponse.bind(this, 'getFile', success, error));
}
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.
get(`${this.getFileRoute(fileId)}/get`).
set(this.defaultHeaders).
type('application/json').
accept('application/json').
end(this.handleResponse.bind(this, 'getFilePreview', success, error));
}
getFileInfo(fileId, success, error) {
request.
get(`${this.getFileRoute(fileId)}/get_info`).
set(this.defaultHeaders).
type('application/json').
accept('application/json').
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 // Routes for OAuth
registerOAuthApp(app, success, error) { registerOAuthApp(app, success, error) {

Просмотреть файл

@@ -76,10 +76,8 @@ export default class AudioVideoPreview extends React.Component {
if (!this.state.canPlay) { if (!this.state.canPlay) {
return ( return (
<FileInfoPreview <FileInfoPreview
filename={this.props.filename}
fileUrl={this.props.fileUrl}
fileInfo={this.props.fileInfo} 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 // add a key to the video to prevent React from using an old video source while a new one is loading
return ( return (
<video <video
key={this.props.filename} key={this.props.fileInfo.id}
ref='video' ref='video'
style={{maxHeight: this.props.maxHeight}} style={{maxHeight: this.props.maxHeight}}
data-setup='{}' data-setup='{}'
@@ -112,9 +110,7 @@ export default class AudioVideoPreview extends React.Component {
} }
AudioVideoPreview.propTypes = { AudioVideoPreview.propTypes = {
filename: React.PropTypes.string.isRequired,
fileUrl: React.PropTypes.string.isRequired,
fileInfo: React.PropTypes.object.isRequired, fileInfo: React.PropTypes.object.isRequired,
maxHeight: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.number]).isRequired, fileUrl: React.PropTypes.string.isRequired,
formatMessage: React.PropTypes.func.isRequired maxHeight: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.number]).isRequired
}; };

Просмотреть файл

@@ -38,7 +38,7 @@ export default class CodePreview extends React.Component {
} }
updateStateFromProps(props) { 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) { if (!usedLanguage || props.fileInfo.size > Constants.CODE_PREVIEW_MAX_FILE_SIZE) {
this.setState({code: '', lang: '', loading: false, success: false}); 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}); this.setState({loading: false, success: false});
} }
static support(filename) { static supports(fileInfo) {
return Boolean(SyntaxHighlighting.getLanguageFromFilename(filename)); return Boolean(SyntaxHighlighting.getLanguageFromFileExtension(fileInfo.extension));
} }
render() { render() {
@@ -83,10 +83,8 @@ export default class CodePreview extends React.Component {
if (!this.state.success) { if (!this.state.success) {
return ( return (
<FileInfoPreview <FileInfoPreview
filename={this.props.filename}
fileUrl={this.props.fileUrl}
fileInfo={this.props.fileInfo} 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 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 ( return (
<div className='post-code'> <div className='post-code'>
<span className='post-code__language'> <span className='post-code__language'>
{`${fileName} - ${language}`} {`${this.props.fileInfo.name} - ${language}`}
</span> </span>
<code className='hljs'> <code className='hljs'>
<table> <table>
@@ -129,8 +125,6 @@ export default class CodePreview extends React.Component {
} }
CodePreview.propTypes = { CodePreview.propTypes = {
filename: React.PropTypes.string.isRequired,
fileUrl: React.PropTypes.string.isRequired,
fileInfo: React.PropTypes.object.isRequired, fileInfo: React.PropTypes.object.isRequired,
formatMessage: React.PropTypes.func.isRequired fileUrl: React.PropTypes.string.isRequired
}; };

Просмотреть файл

@@ -55,7 +55,7 @@ export default class CreateComment extends React.Component {
this.state = { this.state = {
messageText: draft.message, messageText: draft.message,
uploadsInProgress: draft.uploadsInProgress, uploadsInProgress: draft.uploadsInProgress,
previews: draft.previews, fileInfos: draft.fileInfos,
submitting: false, submitting: false,
ctrlSend: PreferenceStore.getBool(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter'), ctrlSend: PreferenceStore.getBool(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter'),
showPostDeletedModal: false showPostDeletedModal: false
@@ -99,10 +99,10 @@ export default class CreateComment extends React.Component {
} }
const post = {}; const post = {};
post.filenames = []; post.file_ids = [];
post.message = this.state.messageText; 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; return;
} }
@@ -126,7 +126,7 @@ export default class CreateComment extends React.Component {
post.channel_id = this.props.channelId; post.channel_id = this.props.channelId;
post.root_id = this.props.rootId; post.root_id = this.props.rootId;
post.parent_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(); const time = Utils.getTimestamp();
post.pending_post_id = `${userId}:${time}`; post.pending_post_id = `${userId}:${time}`;
post.user_id = userId; post.user_id = userId;
@@ -163,7 +163,7 @@ export default class CreateComment extends React.Component {
messageText: '', messageText: '',
submitting: false, submitting: false,
postError: null, postError: null,
previews: [], fileInfos: [],
serverError: null serverError: null
}); });
} }
@@ -245,7 +245,7 @@ export default class CreateComment extends React.Component {
this.focusTextbox(); this.focusTextbox();
} }
handleFileUploadComplete(filenames, clientIds) { handleFileUploadComplete(fileInfos, clientIds) {
const draft = PostStore.getCommentDraft(this.props.rootId); const draft = PostStore.getCommentDraft(this.props.rootId);
// remove each finished file from uploads // 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); 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) { handleUploadError(err, clientId) {
@@ -281,11 +281,11 @@ export default class CreateComment extends React.Component {
} }
removePreview(id) { removePreview(id) {
const previews = this.state.previews; const fileInfos = this.state.fileInfos;
const uploadsInProgress = this.state.uploadsInProgress; const uploadsInProgress = this.state.uploadsInProgress;
// id can either be the path of an uploaded file or the client id of an in progress upload // id can either be the id of an uploaded file or the client id of an in progress upload
let index = previews.indexOf(id); let index = fileInfos.findIndex((info) => info.id === id);
if (index === -1) { if (index === -1) {
index = uploadsInProgress.indexOf(id); index = uploadsInProgress.indexOf(id);
@@ -294,26 +294,26 @@ export default class CreateComment extends React.Component {
this.refs.fileUpload.getWrappedInstance().cancelUpload(id); this.refs.fileUpload.getWrappedInstance().cancelUpload(id);
} }
} else { } else {
previews.splice(index, 1); fileInfos.splice(index, 1);
} }
const draft = PostStore.getCommentDraft(this.props.rootId); const draft = PostStore.getCommentDraft(this.props.rootId);
draft.previews = previews; draft.fileInfos = fileInfos;
draft.uploadsInProgress = uploadsInProgress; draft.uploadsInProgress = uploadsInProgress;
PostStore.storeCommentDraft(this.props.rootId, draft); PostStore.storeCommentDraft(this.props.rootId, draft);
this.setState({previews, uploadsInProgress}); this.setState({fileInfos, uploadsInProgress});
} }
componentWillReceiveProps(newProps) { componentWillReceiveProps(newProps) {
if (newProps.rootId !== this.props.rootId) { if (newProps.rootId !== this.props.rootId) {
const draft = PostStore.getCommentDraft(newProps.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() { getFileCount() {
return this.state.previews.length + this.state.uploadsInProgress.length; return this.state.fileInfos.length + this.state.uploadsInProgress.length;
} }
focusTextbox() { focusTextbox() {
@@ -350,10 +350,10 @@ export default class CreateComment extends React.Component {
} }
let preview = null; 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 = ( preview = (
<FilePreview <FilePreview
files={this.state.previews} fileInfos={this.state.fileInfos}
onRemove={this.removePreview} onRemove={this.removePreview}
uploadsInProgress={this.state.uploadsInProgress} uploadsInProgress={this.state.uploadsInProgress}
/> />

Просмотреть файл

@@ -67,7 +67,7 @@ export default class CreatePost extends React.Component {
channelId: ChannelStore.getCurrentId(), channelId: ChannelStore.getCurrentId(),
messageText: draft.messageText, messageText: draft.messageText,
uploadsInProgress: draft.uploadsInProgress, uploadsInProgress: draft.uploadsInProgress,
previews: draft.previews, fileInfos: draft.fileInfos,
submitting: false, submitting: false,
initialText: draft.messageText, initialText: draft.messageText,
ctrlSend: PreferenceStore.getBool(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter'), ctrlSend: PreferenceStore.getBool(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter'),
@@ -79,14 +79,14 @@ export default class CreatePost extends React.Component {
getCurrentDraft() { getCurrentDraft() {
const draft = PostStore.getCurrentDraft(); const draft = PostStore.getCurrentDraft();
const safeDraft = {previews: [], messageText: '', uploadsInProgress: []}; const safeDraft = {fileInfos: [], messageText: '', uploadsInProgress: []};
if (draft) { if (draft) {
if (draft.message) { if (draft.message) {
safeDraft.messageText = draft.message; safeDraft.messageText = draft.message;
} }
if (draft.previews) { if (draft.fileInfos) {
safeDraft.previews = draft.previews; safeDraft.fileInfos = draft.fileInfos;
} }
if (draft.uploadsInProgress) { if (draft.uploadsInProgress) {
safeDraft.uploadsInProgress = draft.uploadsInProgress; safeDraft.uploadsInProgress = draft.uploadsInProgress;
@@ -104,10 +104,10 @@ export default class CreatePost extends React.Component {
} }
const post = {}; const post = {};
post.filenames = []; post.file_ids = [];
post.message = this.state.messageText; 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; return;
} }
@@ -122,7 +122,7 @@ export default class CreatePost extends React.Component {
if (post.message.indexOf('/') === 0) { if (post.message.indexOf('/') === 0) {
PostStore.storeDraft(this.state.channelId, null); PostStore.storeDraft(this.state.channelId, null);
this.setState({messageText: '', postError: null, previews: []}); this.setState({messageText: '', postError: null, fileInfos: []});
ChannelActions.executeCommand( ChannelActions.executeCommand(
this.state.channelId, this.state.channelId,
@@ -153,7 +153,7 @@ export default class CreatePost extends React.Component {
sendMessage(post) { sendMessage(post) {
post.channel_id = this.state.channelId; 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 time = Utils.getTimestamp();
const userId = UserStore.getCurrentId(); const userId = UserStore.getCurrentId();
@@ -163,7 +163,7 @@ export default class CreatePost extends React.Component {
post.parent_id = this.state.parentId; post.parent_id = this.state.parentId;
GlobalActions.emitUserPostedEvent(post); 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, Client.createPost(post,
(data) => { (data) => {
@@ -236,7 +236,7 @@ export default class CreatePost extends React.Component {
this.focusTextbox(); this.focusTextbox();
} }
handleFileUploadComplete(filenames, clientIds, channelId) { handleFileUploadComplete(fileInfos, clientIds, channelId) {
const draft = PostStore.getDraft(channelId); const draft = PostStore.getDraft(channelId);
// remove each finished file from uploads // 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); PostStore.storeDraft(channelId, draft);
if (channelId === this.state.channelId) { 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) { removePreview(id) {
const previews = Object.assign([], this.state.previews); const fileInfos = Object.assign([], this.state.fileInfos);
const uploadsInProgress = this.state.uploadsInProgress; const uploadsInProgress = this.state.uploadsInProgress;
// id can either be the path of an uploaded file or the client id of an in progress upload // id can either be the id of an uploaded file or the client id of an in progress upload
let index = previews.indexOf(id); let index = fileInfos.findIndex((info) => info.id === id);
if (index === -1) { if (index === -1) {
index = uploadsInProgress.indexOf(id); index = uploadsInProgress.indexOf(id);
@@ -295,15 +295,15 @@ export default class CreatePost extends React.Component {
this.refs.fileUpload.getWrappedInstance().cancelUpload(id); this.refs.fileUpload.getWrappedInstance().cancelUpload(id);
} }
} else { } else {
previews.splice(index, 1); fileInfos.splice(index, 1);
} }
const draft = PostStore.getCurrentDraft(); const draft = PostStore.getCurrentDraft();
draft.previews = previews; draft.fileInfos = fileInfos;
draft.uploadsInProgress = uploadsInProgress; draft.uploadsInProgress = uploadsInProgress;
PostStore.storeCurrentDraft(draft); PostStore.storeCurrentDraft(draft);
this.setState({previews, uploadsInProgress}); this.setState({fileInfos, uploadsInProgress});
} }
componentWillMount() { componentWillMount() {
@@ -336,6 +336,7 @@ export default class CreatePost extends React.Component {
PreferenceStore.removeChangeListener(this.onPreferenceChange); PreferenceStore.removeChangeListener(this.onPreferenceChange);
document.removeEventListener('keydown', this.showShortcuts); document.removeEventListener('keydown', this.showShortcuts);
} }
showShortcuts(e) { showShortcuts(e) {
if ((e.ctrlKey || e.metaKey) && e.keyCode === Constants.KeyCodes.FORWARD_SLASH) { if ((e.ctrlKey || e.metaKey) && e.keyCode === Constants.KeyCodes.FORWARD_SLASH) {
e.preventDefault(); e.preventDefault();
@@ -359,7 +360,7 @@ export default class CreatePost extends React.Component {
if (this.state.channelId !== channelId) { if (this.state.channelId !== channelId) {
const draft = this.getCurrentDraft(); 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) { getFileCount(channelId) {
if (channelId === this.state.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); const draft = PostStore.getDraft(channelId);
return draft.previews.length + draft.uploadsInProgress.length; return draft.fileInfos.length + draft.uploadsInProgress.length;
} }
handleKeyDown(e) { handleKeyDown(e) {
@@ -474,10 +475,10 @@ export default class CreatePost extends React.Component {
} }
let preview = null; 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 = ( preview = (
<FilePreview <FilePreview
files={this.state.previews} fileInfos={this.state.fileInfos}
onRemove={this.removePreview} onRemove={this.removePreview}
uploadsInProgress={this.state.uploadsInProgress} uploadsInProgress={this.state.uploadsInProgress}
/> />

Просмотреть файл

@@ -1,204 +1,111 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information. // 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 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'; import {Tooltip, OverlayTrigger} from 'react-bootstrap';
const holders = defineMessages({
download: {
id: 'file_attachment.download',
defaultMessage: 'Download'
}
});
import React from 'react'; import React from 'react';
class FileAttachment extends React.Component { export default class FileAttachment extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.loadFiles = this.loadFiles.bind(this); this.loadFiles = this.loadFiles.bind(this);
this.addBackgroundImage = this.addBackgroundImage.bind(this);
this.onAttachmentClick = this.onAttachmentClick.bind(this); this.onAttachmentClick = this.onAttachmentClick.bind(this);
this.canSetState = false; this.state = {
this.state = {fileSize: -1}; loaded: Utils.getFileType(props.fileInfo.extension) !== 'image'
};
} }
componentDidMount() { componentDidMount() {
this.loadFiles(); this.loadFiles();
} }
componentWillReceiveProps(nextProps) {
if (nextProps.fileInfo.id !== this.props.fileInfo.id) {
this.setState({
loaded: Utils.getFileType(nextProps.fileInfo.extension) !== 'image'
});
}
}
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (this.props.filename !== prevProps.filename) { if (!this.state.loaded && this.props.fileInfo.id !== prevProps.fileInfo.id) {
this.loadFiles(); this.loadFiles();
} }
} }
loadFiles() { loadFiles() {
this.canSetState = true; const fileInfo = this.props.fileInfo;
const fileType = Utils.getFileType(fileInfo.extension);
var filename = this.props.filename; if (fileType === 'image') {
const thumbnailUrl = FileStore.getFileThumbnailUrl(fileInfo.id);
if (filename) { const img = new Image();
var fileInfo = this.getFileInfoFromName(filename); img.onload = () => {
var type = utils.getFileType(fileInfo.ext); this.setState({loaded: true});
};
if (type === 'image') { img.load(thumbnailUrl);
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)));
}
} }
} }
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;
}
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;
}
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');
$(imgDiv).css('background-image', 'url(' + url + '_thumb.jpg)');
}
}
removeBackgroundImage(name) {
if (name in this.refs) {
$(ReactDOM.findDOMNode(this.refs[name])).css('background-image', 'initial');
}
}
onAttachmentClick(e) { onAttachmentClick(e) {
e.preventDefault(); e.preventDefault();
this.props.handleImageClick(this.props.index); this.props.handleImageClick(this.props.index);
} }
render() { 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); let thumbnail;
var fileUrl = utils.getFileUrl(filename); if (this.state.loaded) {
var type = utils.getFileType(fileInfo.ext); const type = Utils.getFileType(fileInfo.extension);
var thumbnail; if (type === 'image') {
if (type === 'image') { let className = 'post-image';
thumbnail = (
<div
ref={filename}
className='post-image__load'
/>
);
} else {
thumbnail = <div className={'file-icon ' + utils.getIconClassName(type)}/>;
}
var fileSizeString = ''; if (fileInfo.width < Constants.THUMBNAIL_WIDTH && fileInfo.height < Constants.THUMBNAIL_HEIGHT) {
if (this.state.fileSize < 0) { className += ' small';
Client.getFileInfo( } else {
filename, className += ' normal';
(data) => {
if (this.canSetState) {
this.setState({fileSize: parseInt(data.size, 10)});
}
},
() => {
// Do nothing
} }
);
thumbnail = (
<div
className={className}
style={{
backgroundImage: `url(${FileStore.getFileThumbnailUrl(fileInfo.id)})`
}}
/>
);
} else {
thumbnail = <div className={'file-icon ' + Utils.getIconClassName(type)}/>;
}
} else { } else {
fileSizeString = utils.fileSizeToString(this.state.fileSize); thumbnail = <div className='post-image__load'/>;
} }
var filenameString = decodeURIComponent(utils.getFileName(filename)); let trimmedFilename;
var trimmedFilename; if (fileName.length > 35) {
if (filenameString.length > 35) { trimmedFilename = fileName.substring(0, Math.min(35, fileName.length)) + '...';
trimmedFilename = filenameString.substring(0, Math.min(35, filenameString.length)) + '...';
} else { } 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) { if (this.props.compactDisplay) {
filenameOverlay = ( filenameOverlay = (
<OverlayTrigger <OverlayTrigger
delayShow={1000} delayShow={1000}
placement='top' placement='top'
overlay={<Tooltip id='file-name__tooltip'>{filenameString}</Tooltip>} overlay={<Tooltip id='file-name__tooltip'>{fileName}</Tooltip>}
> >
<a <a
href='#' href='#'
@@ -214,13 +121,28 @@ class FileAttachment extends React.Component {
</a> </a>
</OverlayTrigger> </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 ( return (
<div <div className='post-image__column'>
className='post-image__column'
key={filename}
>
<a <a
className='post-image__thumbnail' className='post-image__thumbnail'
href='#' href='#'
@@ -233,17 +155,15 @@ class FileAttachment extends React.Component {
<div> <div>
<a <a
href={fileUrl} href={fileUrl}
download={filenameString} download={fileName}
className='post-image__download' className='post-image__download'
target='_blank' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'
> >
<span <span className='fa fa-download'/>
className='fa fa-download'
/>
</a> </a>
<span className='post-image__type'>{fileInfo.ext.toUpperCase()}</span> <span className='post-image__type'>{fileInfo.extension.toUpperCase()}</span>
<span className='post-image__size'>{fileSizeString}</span> <span className='post-image__size'>{Utils.fileSizeToString(fileInfo.size)}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -252,10 +172,7 @@ class FileAttachment extends React.Component {
} }
FileAttachment.propTypes = { FileAttachment.propTypes = {
intl: intlShape.isRequired, fileInfo: React.PropTypes.object.isRequired,
// a list of file pathes displayed by the parent FileAttachmentList
filename: React.PropTypes.string.isRequired,
// the index of this attachment preview in the parent FileAttachmentList // the index of this attachment preview in the parent FileAttachmentList
index: React.PropTypes.number.isRequired, index: React.PropTypes.number.isRequired,
@@ -264,6 +181,4 @@ FileAttachment.propTypes = {
handleImageClick: React.PropTypes.func, handleImageClick: React.PropTypes.func,
compactDisplay: React.PropTypes.bool compactDisplay: React.PropTypes.bool
}; };
export default injectIntl(FileAttachment);

Просмотреть файл

@@ -13,25 +13,34 @@ export default class FileAttachmentList extends React.Component {
this.handleImageClick = this.handleImageClick.bind(this); 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});
}
render() {
var filenames = this.props.filenames;
var postFiles = []; handleImageClick(indexClicked) {
for (var i = 0; i < filenames.length && i < Constants.MAX_DISPLAY_FILES; i++) { this.setState({showPreviewModal: true, startImgIndex: indexClicked});
postFiles.push( }
<FileAttachment
key={'file_attachment_' + i} render() {
filename={filenames[i]} const postFiles = [];
index={i} if (this.props.fileInfos && this.props.fileInfos.length > 0) {
handleImageClick={this.handleImageClick} for (let i = 0; i < Math.min(this.props.fileInfos.length, Constants.MAX_DISPLAY_FILES); i++) {
compactDisplay={this.props.compactDisplay} const fileInfo = this.props.fileInfos[i];
/>
); 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 ( return (
@@ -42,10 +51,8 @@ export default class FileAttachmentList extends React.Component {
<ViewImageModal <ViewImageModal
show={this.state.showPreviewModal} show={this.state.showPreviewModal}
onModalDismissed={() => this.setState({showPreviewModal: false})} onModalDismissed={() => this.setState({showPreviewModal: false})}
channelId={this.props.channelId} startId={this.state.startImgIndex}
userId={this.props.userId} fileInfos={this.props.fileInfos}
startId={this.state.startImgId}
filenames={filenames}
/> />
</div> </div>
); );
@@ -53,15 +60,7 @@ export default class FileAttachmentList extends React.Component {
} }
FileAttachmentList.propTypes = { FileAttachmentList.propTypes = {
fileCount: React.PropTypes.number.isRequired,
// a list of file pathes displayed by this fileInfos: React.PropTypes.arrayOf(React.PropTypes.object).isRequired,
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,
compactDisplay: React.PropTypes.bool compactDisplay: React.PropTypes.bool
}; };

Просмотреть файл

@@ -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}
/>
);
}
}

Просмотреть файл

@@ -1,59 +1,59 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information. // See License.txt for license information.
import React from 'react';
import * as Utils from 'utils/utils.jsx'; import * as Utils from 'utils/utils.jsx';
import {defineMessages} from 'react-intl'; export default class FileInfoPreview extends React.Component {
import React from 'react'; shouldComponentUpdate(nextProps) {
import {Link} from 'react-router/es6'; if (nextProps.fileUrl !== this.props.fileUrl) {
return true;
}
const holders = defineMessages({ if (!Utils.areObjectsEqual(nextProps.fileInfo, this.props.fileInfo)) {
type: { return true;
id: 'file_info_preview.type', }
defaultMessage: 'File type '
},
size: {
id: 'file_info_preview.size',
defaultMessage: 'Size '
}
});
export default function FileInfoPreview({filename, fileUrl, fileInfo, formatMessage}) { return false;
// non-image files include a section providing details about the file
const infoParts = [];
if (fileInfo.extension !== '') {
infoParts.push(formatMessage(holders.type) + fileInfo.extension.toUpperCase());
} }
infoParts.push(formatMessage(holders.size) + Utils.fileSizeToString(fileInfo.size)); render() {
const fileInfo = this.props.fileInfo;
const fileUrl = this.props.fileUrl;
const infoString = infoParts.join(', '); // non-image files include a section providing details about the file
const infoParts = [];
const name = decodeURIComponent(Utils.getFileName(filename)); if (fileInfo.extension !== '') {
infoParts.push(Utils.localizeMessage('file_info_preview.type', 'File type ') + fileInfo.extension.toUpperCase());
}
return ( infoParts.push(Utils.localizeMessage('file_info_preview.size', 'Size ') + Utils.fileSizeToString(fileInfo.size));
<div className='file-details__container'>
<Link const infoString = infoParts.join(', ');
className={'file-details__preview'}
to={fileUrl} return (
target='_blank' <div className='file-details__container'>
rel='noopener noreferrer' <a
> className={'file-details__preview'}
<span className='file-details__preview-helper'/> to={fileUrl}
<img src={Utils.getPreviewImagePath(filename)}/> target='_blank'
</Link> rel='noopener noreferrer'
<div className='file-details'> >
<div className='file-details__name'>{name}</div> <span className='file-details__preview-helper'/>
<div className='file-details__info'>{infoString}</div> <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>
</div> );
); }
} }
FileInfoPreview.propTypes = { FileInfoPreview.propTypes = {
filename: React.PropTypes.string.isRequired,
fileUrl: React.PropTypes.string.isRequired,
fileInfo: React.PropTypes.object.isRequired, fileInfo: React.PropTypes.object.isRequired,
formatMessage: React.PropTypes.func.isRequired fileUrl: React.PropTypes.string.isRequired
}; };

Просмотреть файл

@@ -1,6 +1,7 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information. // See License.txt for license information.
import FileStore from 'stores/file_store.jsx';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import * as Utils from 'utils/utils.jsx'; import * as Utils from 'utils/utils.jsx';
@@ -21,63 +22,43 @@ export default class FilePreview extends React.Component {
} }
} }
handleRemove(e) { handleRemove(id) {
var previewDiv = e.target.parentNode.parentNode; this.props.onRemove(id);
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'));
}
} }
render() { render() {
var previews = []; var previews = [];
this.props.files.forEach((fullFilename) => { this.props.fileInfos.forEach((info) => {
var filename = fullFilename; const type = Utils.getFileType(info.extension);
var originalFilename = filename;
var filenameSplit = filename.split('.');
var ext = filenameSplit[filenameSplit.length - 1];
var type = Utils.getFileType(ext);
filename = Utils.getFileUrl(filename);
let className = 'file-preview';
let previewImage;
if (type === 'image') { if (type === 'image') {
previews.push( previewImage = (
<div <img
key={filename} className='file-preview__image'
className='file-preview' src={FileStore.getFileUrl(info.id)}
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>
); );
} else { } else {
previews.push( className += ' custom-file';
<div previewImage = <div className={'file-icon ' + Utils.getIconClassName(type)}/>;
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>
);
} }
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) => { this.props.uploadsInProgress.forEach((clientId) => {
@@ -94,7 +75,7 @@ export default class FilePreview extends React.Component {
/> />
<a <a
className='file-preview__remove' className='file-preview__remove'
onClick={this.handleRemove} onClick={this.handleRemove.bind(this, clientId)}
> >
<i className='fa fa-remove'/> <i className='fa fa-remove'/>
</a> </a>
@@ -111,11 +92,11 @@ export default class FilePreview extends React.Component {
} }
FilePreview.defaultProps = { FilePreview.defaultProps = {
files: [], fileInfos: [],
uploadsInProgress: [] uploadsInProgress: []
}; };
FilePreview.propTypes = { FilePreview.propTypes = {
onRemove: React.PropTypes.func.isRequired, onRemove: React.PropTypes.func.isRequired,
files: React.PropTypes.array, fileInfos: React.PropTypes.arrayOf(React.PropTypes.object).isRequired,
uploadsInProgress: React.PropTypes.array uploadsInProgress: React.PropTypes.array
}; };

Просмотреть файл

@@ -49,13 +49,12 @@ class FileUpload extends React.Component {
this.keyUpload = this.keyUpload.bind(this); this.keyUpload = this.keyUpload.bind(this);
this.state = { this.state = {
maxFileSize: global.window.mm_config.MaxFileSize,
requests: {} requests: {}
}; };
} }
fileUploadSuccess(channelId, data) { 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); const requests = Object.assign({}, this.state.requests);
for (var j = 0; j < data.client_ids.length; j++) { for (var j = 0; j < data.client_ids.length; j++) {
@@ -81,7 +80,7 @@ class FileUpload extends React.Component {
const tooLargeFiles = []; const tooLargeFiles = [];
for (let i = 0; i < files.length && numUploads < uploadsRemaining; i++) { 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]); tooLargeFiles.push(files[i]);
continue; continue;
} }
@@ -112,9 +111,9 @@ class FileUpload extends React.Component {
} else if (tooLargeFiles.length > 1) { } else if (tooLargeFiles.length > 1) {
var tooLargeFilenames = tooLargeFiles.map((file) => file.name).join(', '); 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) { } 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}));
} }
} }

Просмотреть файл

@@ -23,7 +23,7 @@ export default class GetPublicLinkModal extends React.Component {
this.state = { this.state = {
show: false, show: false,
filename: '', fileId: '',
link: '' link: ''
}; };
} }
@@ -34,7 +34,7 @@ export default class GetPublicLinkModal extends React.Component {
componentDidUpdate(prevProps, prevState) { componentDidUpdate(prevProps, prevState) {
if (this.state.show && !prevState.show) { 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) { handleToggle(value, args) {
this.setState({ this.setState({
show: value, show: value,
filename: args.filename, fileId: args.fileId,
link: '' link: ''
}); });
} }

Просмотреть файл

@@ -3,8 +3,6 @@
import FileInfoPreview from './file_info_preview.jsx'; import FileInfoPreview from './file_info_preview.jsx';
import * as Utils from 'utils/utils.jsx';
import loadingGif from 'images/load.gif'; import loadingGif from 'images/load.gif';
import React from 'react'; import React from 'react';
@@ -109,18 +107,8 @@ export default class PDFPreview extends React.Component {
} }
} }
static support(filename) { static supports(fileInfo) {
const fileInfo = Utils.splitFileLocation(filename); return fileInfo.extension === 'pdf';
const ext = fileInfo.ext;
if (!ext) {
return false;
}
if (ext === 'pdf') {
return true;
}
return false;
} }
render() { render() {
@@ -138,10 +126,8 @@ export default class PDFPreview extends React.Component {
if (!this.state.success) { if (!this.state.success) {
return ( return (
<FileInfoPreview <FileInfoPreview
filename={this.props.filename}
fileUrl={this.props.fileUrl}
fileInfo={this.props.fileInfo} 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 = { PDFPreview.propTypes = {
filename: React.PropTypes.string.isRequired,
fileUrl: React.PropTypes.string.isRequired,
fileInfo: React.PropTypes.object.isRequired, fileInfo: React.PropTypes.object.isRequired,
formatMessage: React.PropTypes.func.isRequired fileUrl: React.PropTypes.string.isRequired
}; };

Просмотреть файл

@@ -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>;
}
}

Просмотреть файл

@@ -1,11 +1,12 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information. // See License.txt for license information.
import FileAttachmentList from 'components/file_attachment_list.jsx';
import UserStore from 'stores/user_store.jsx'; import UserStore from 'stores/user_store.jsx';
import * as Utils from 'utils/utils.jsx'; import * as Utils from 'utils/utils.jsx';
import * as GlobalActions from 'actions/global_actions.jsx'; import * as GlobalActions from 'actions/global_actions.jsx';
import Constants from 'utils/constants.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 PostBodyAdditionalContent from './post_body_additional_content.jsx';
import PostMessageContainer from './post_message_container.jsx'; import PostMessageContainer from './post_message_container.jsx';
import PendingPostOptions from './pending_post_options.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); this.removePost = this.removePost.bind(this);
} }
shouldComponentUpdate(nextProps) { shouldComponentUpdate(nextProps) {
if (nextProps.isCommentMention !== this.props.isCommentMention) { if (nextProps.isCommentMention !== this.props.isCommentMention) {
return true; return true;
@@ -56,7 +58,6 @@ export default class PostBody extends React.Component {
render() { render() {
const post = this.props.post; const post = this.props.post;
const filenames = this.props.post.filenames;
const parentPost = this.props.parentPost; const parentPost = this.props.parentPost;
let comment = ''; let comment = '';
@@ -94,14 +95,13 @@ export default class PostBody extends React.Component {
let message = ''; let message = '';
if (parentPost.message) { if (parentPost.message) {
message = Utils.replaceHtmlEntities(parentPost.message); message = Utils.replaceHtmlEntities(parentPost.message);
} else if (parentPost.filenames.length) { } else if (parentPost.file_ids && parentPost.file_ids.length > 0) {
message = parentPost.filenames[0].split('/').pop(); message = (
<CommentedOnFilesMessageContainer
if (parentPost.filenames.length === 2) { parentPostChannelId={parentPost.channel_id}
message += Utils.localizeMessage('post_body.plusOne', ' plus 1 other file'); parentPostId={parentPost.id}
} else if (parentPost.filenames.length > 2) { />
message += Utils.localizeMessage('post_body.plusMore', ' plus {count} other files').replace('{count}', (parentPost.filenames.length - 1).toString()); );
}
} }
comment = ( comment = (
@@ -140,14 +140,11 @@ export default class PostBody extends React.Component {
); );
} }
let fileAttachmentHolder = ''; let fileAttachmentHolder = null;
if (filenames && filenames.length > 0) { if ((post.file_ids && post.file_ids.length > 0) || (post.filenames && post.filenames.length > 0)) {
fileAttachmentHolder = ( fileAttachmentHolder = (
<FileAttachmentList <FileAttachmentListContainer
post={post}
filenames={filenames}
channelId={post.channel_id}
userId={post.user_id}
compactDisplay={this.props.compactDisplay} compactDisplay={this.props.compactDisplay}
/> />
); );

Просмотреть файл

@@ -2,7 +2,7 @@
// See License.txt for license information. // See License.txt for license information.
import UserProfile from './user_profile.jsx'; 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 PendingPostOptions from 'components/post_view/components/pending_post_options.jsx';
import PostMessageContainer from 'components/post_view/components/post_message_container.jsx'; import PostMessageContainer from 'components/post_view/components/post_message_container.jsx';
import ProfilePicture from 'components/profile_picture.jsx'; import ProfilePicture from 'components/profile_picture.jsx';
@@ -295,13 +295,11 @@ export default class RhsComment extends React.Component {
var dropdown = this.createDropdown(); var dropdown = this.createDropdown();
var fileAttachment; let fileAttachment = null;
if (post.filenames && post.filenames.length > 0) { if (post.file_ids && post.file_ids.length > 0) {
fileAttachment = ( fileAttachment = (
<FileAttachmentList <FileAttachmentListContainer
filenames={post.filenames} post={post}
channelId={post.channel_id}
userId={post.user_id}
compactDisplay={this.props.compactDisplay} compactDisplay={this.props.compactDisplay}
/> />
); );

Просмотреть файл

@@ -4,7 +4,7 @@
import UserProfile from './user_profile.jsx'; import UserProfile from './user_profile.jsx';
import PostBodyAdditionalContent from 'components/post_view/components/post_body_additional_content.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 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 ProfilePicture from 'components/profile_picture.jsx';
import ChannelStore from 'stores/channel_store.jsx'; import ChannelStore from 'stores/channel_store.jsx';
@@ -242,13 +242,11 @@ export default class RhsRootPost extends React.Component {
); );
} }
var fileAttachment; let fileAttachment = null;
if (post.filenames && post.filenames.length > 0) { if (post.file_ids && post.file_ids.length > 0) {
fileAttachment = ( fileAttachment = (
<FileAttachmentList <FileAttachmentListContainer
filenames={post.filenames} post={post}
channelId={post.channel_id}
userId={post.user_id}
compactDisplay={this.props.compactDisplay} compactDisplay={this.props.compactDisplay}
/> />
); );

Просмотреть файл

@@ -11,7 +11,6 @@ import * as GlobalActions from 'actions/global_actions.jsx';
import FileStore from 'stores/file_store.jsx'; import FileStore from 'stores/file_store.jsx';
import * as AsyncClient from 'utils/async_client.jsx';
import * as Utils from 'utils/utils.jsx'; import * as Utils from 'utils/utils.jsx';
import Constants from 'utils/constants.jsx'; import Constants from 'utils/constants.jsx';
@@ -19,19 +18,11 @@ const KeyCodes = Constants.KeyCodes;
import $ from 'jquery'; import $ from 'jquery';
import React from 'react'; import React from 'react';
import {intlShape, injectIntl, defineMessages} from 'react-intl';
import {Modal} from 'react-bootstrap'; import {Modal} from 'react-bootstrap';
import loadingGif from 'images/load.gif'; import loadingGif from 'images/load.gif';
const holders = defineMessages({ export default class ViewImageModal extends React.Component {
loading: {
id: 'view_image.loading',
defaultMessage: 'Loading '
}
});
class ViewImageModal extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
@@ -45,18 +36,15 @@ class ViewImageModal extends React.Component {
this.onModalShown = this.onModalShown.bind(this); this.onModalShown = this.onModalShown.bind(this);
this.onModalHidden = this.onModalHidden.bind(this); this.onModalHidden = this.onModalHidden.bind(this);
this.onFileStoreChange = this.onFileStoreChange.bind(this);
this.handleGetPublicLink = this.handleGetPublicLink.bind(this); this.handleGetPublicLink = this.handleGetPublicLink.bind(this);
this.onMouseEnterImage = this.onMouseEnterImage.bind(this); this.onMouseEnterImage = this.onMouseEnterImage.bind(this);
this.onMouseLeaveImage = this.onMouseLeaveImage.bind(this); this.onMouseLeaveImage = this.onMouseLeaveImage.bind(this);
this.state = { this.state = {
imgId: this.props.startId, imgId: this.props.startId,
fileInfo: null,
imgHeight: '100%', imgHeight: '100%',
loaded: Utils.fillArray(false, this.props.filenames.length), loaded: Utils.fillArray(false, this.props.fileInfos.length),
progress: Utils.fillArray(0, this.props.filenames.length), progress: Utils.fillArray(0, this.props.fileInfos.length),
showFooter: false showFooter: false
}; };
} }
@@ -66,7 +54,7 @@ class ViewImageModal extends React.Component {
e.stopPropagation(); e.stopPropagation();
} }
let id = this.state.imgId + 1; let id = this.state.imgId + 1;
if (id > this.props.filenames.length - 1) { if (id > this.props.fileInfos.length - 1) {
id = 0; id = 0;
} }
this.showImage(id); this.showImage(id);
@@ -78,7 +66,7 @@ class ViewImageModal extends React.Component {
} }
let id = this.state.imgId - 1; let id = this.state.imgId - 1;
if (id < 0) { if (id < 0) {
id = this.props.filenames.length - 1; id = this.props.fileInfos.length - 1;
} }
this.showImage(id); this.showImage(id);
} }
@@ -95,8 +83,6 @@ class ViewImageModal extends React.Component {
$(window).on('keyup', this.handleKeyPress); $(window).on('keyup', this.handleKeyPress);
this.showImage(nextProps.startId); this.showImage(nextProps.startId);
FileStore.addChangeListener(this.onFileStoreChange);
} }
onModalHidden() { onModalHidden() {
@@ -105,8 +91,6 @@ class ViewImageModal extends React.Component {
if (this.refs.video) { if (this.refs.video) {
this.refs.video.stop(); this.refs.video.stop();
} }
FileStore.removeChangeListener(this.onFileStoreChange);
} }
componentWillReceiveProps(nextProps) { componentWillReceiveProps(nextProps) {
@@ -116,64 +100,36 @@ class ViewImageModal extends React.Component {
this.onModalHidden(); this.onModalHidden();
} }
if (!Utils.areObjectsEqual(this.props.filenames, nextProps.filenames)) { if (this.props.fileInfos !== nextProps.fileInfos) {
this.setState({ this.setState({
loaded: Utils.fillArray(false, nextProps.filenames.length), loaded: Utils.fillArray(false, nextProps.fileInfos.length),
progress: Utils.fillArray(0, nextProps.filenames.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) { showImage(id) {
this.setState({imgId: id}); this.setState({imgId: id});
const imgHeight = $(window).height() - 100; const imgHeight = $(window).height() - 100;
this.setState({imgHeight}); 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]) { if (!this.state.loaded[id]) {
this.loadImage(id, filename); this.loadImage(id);
} }
} }
loadImage(id, filename) { loadImage(index) {
const fileInfo = FileStore.getInfo(filename); const fileInfo = this.props.fileInfos[index];
const fileType = Utils.getFileType(fileInfo.extension); const fileType = Utils.getFileType(fileInfo.extension);
if (fileType === 'image') { if (fileType === 'image') {
let previewUrl; let previewUrl;
if (fileInfo.has_image_preview) { if (fileInfo.has_image_preview) {
previewUrl = Utils.getPreviewImagePath(filename); previewUrl = FileStore.getFilePreviewUrl(fileInfo.id);
} else { } else {
// some images (eg animated gifs) just show the file itself and not a preview // 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(); const img = new Image();
@@ -181,19 +137,19 @@ class ViewImageModal extends React.Component {
previewUrl, previewUrl,
() => { () => {
const progress = this.state.progress; const progress = this.state.progress;
progress[id] = img.completedPercentage; progress[index] = img.completedPercentage;
this.setState({progress}); this.setState({progress});
} }
); );
img.onload = () => { img.onload = () => {
const loaded = this.state.loaded; const loaded = this.state.loaded;
loaded[id] = true; loaded[index] = true;
this.setState({loaded}); this.setState({loaded});
}; };
} else { } else {
// there's nothing to load for non-image files // there's nothing to load for non-image files
var loaded = this.state.loaded; var loaded = this.state.loaded;
loaded[id] = true; loaded[index] = true;
this.setState({loaded}); this.setState({loaded});
} }
} }
@@ -201,7 +157,7 @@ class ViewImageModal extends React.Component {
handleGetPublicLink() { handleGetPublicLink() {
this.props.onModalDismissed(); this.props.onModalDismissed();
GlobalActions.showGetPublicLinkModal(this.props.filenames[this.state.imgId]); GlobalActions.showGetPublicLinkModal(this.props.fileInfos[this.state.imgId].id);
} }
onMouseEnterImage() { onMouseEnterImage() {
@@ -213,63 +169,52 @@ class ViewImageModal extends React.Component {
} }
render() { render() {
if (this.props.filenames.length < 1 || this.props.filenames.length - 1 < this.state.imgId) { if (this.props.fileInfos.length < 1 || this.props.fileInfos.length - 1 < this.state.imgId) {
return <div/>; return null;
} }
const filename = this.props.filenames[this.state.imgId]; const fileInfo = this.props.fileInfos[this.state.imgId];
const fileUrl = Utils.getFileUrl(filename); const fileUrl = FileStore.getFileUrl(fileInfo.id);
var content; let content;
if (this.state.loaded[this.state.imgId]) { 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); const fileType = Utils.getFileType(fileInfo.extension);
if (fileType === 'image') { if (fileType === 'image') {
content = ( content = (
<ImagePreview <ImagePreview
filename={filename}
fileUrl={fileUrl}
fileInfo={fileInfo} fileInfo={fileInfo}
fileUrl={fileUrl}
maxHeight={this.state.imgHeight} maxHeight={this.state.imgHeight}
/> />
); );
} else if (fileType === 'video' || fileType === 'audio') { } else if (fileType === 'video' || fileType === 'audio') {
content = ( content = (
<AudioVideoPreview <AudioVideoPreview
filename={filename} fileInfo={fileInfo}
fileUrl={fileUrl} fileUrl={fileUrl}
fileInfo={this.state.fileInfo}
maxHeight={this.state.imgHeight} maxHeight={this.state.imgHeight}
formatMessage={this.props.intl.formatMessage}
/> />
); );
} else if (PDFPreview.support(filename)) { } else if (PDFPreview.supports(fileInfo)) {
content = ( content = (
<PDFPreview <PDFPreview
filename={filename}
fileUrl={fileUrl}
fileInfo={fileInfo} fileInfo={fileInfo}
formatMessage={this.props.intl.formatMessage} fileUrl={fileUrl}
/> />
); );
} else if (CodePreview.support(filename)) { } else if (CodePreview.supports(fileInfo)) {
content = ( content = (
<CodePreview <CodePreview
filename={filename}
fileUrl={fileUrl}
fileInfo={fileInfo} fileInfo={fileInfo}
formatMessage={this.props.intl.formatMessage} fileUrl={fileUrl}
/> />
); );
} else { } else {
content = ( content = (
<FileInfoPreview <FileInfoPreview
filename={filename}
fileUrl={fileUrl}
fileInfo={fileInfo} fileInfo={fileInfo}
formatMessage={this.props.intl.formatMessage} fileUrl={fileUrl}
/> />
); );
} }
@@ -280,14 +225,14 @@ class ViewImageModal extends React.Component {
content = ( content = (
<LoadingImagePreview <LoadingImagePreview
progress={progress} progress={progress}
loading={this.props.intl.formatMessage(holders.loading)} loading={Utils.localizeMessage('view_image.loading', 'Loading ')}
/> />
); );
} }
let leftArrow = null; let leftArrow = null;
let rightArrow = null; let rightArrow = null;
if (this.props.filenames.length > 1) { if (this.props.fileInfos.length > 1) {
leftArrow = ( leftArrow = (
<a <a
ref='previewArrowLeft' ref='previewArrowLeft'
@@ -346,8 +291,8 @@ class ViewImageModal extends React.Component {
<ViewImagePopoverBar <ViewImagePopoverBar
show={this.state.showFooter} show={this.state.showFooter}
fileId={this.state.imgId} fileId={this.state.imgId}
totalFiles={this.props.filenames.length} totalFiles={this.props.fileInfos.length}
filename={name} filename={fileInfo.name}
fileURL={fileUrl} fileURL={fileUrl}
onGetPublicLink={this.handleGetPublicLink} onGetPublicLink={this.handleGetPublicLink}
/> />
@@ -363,19 +308,13 @@ class ViewImageModal extends React.Component {
ViewImageModal.defaultProps = { ViewImageModal.defaultProps = {
show: false, show: false,
filenames: [], fileInfos: [],
channelId: '',
userId: '',
startId: 0 startId: 0
}; };
ViewImageModal.propTypes = { ViewImageModal.propTypes = {
intl: intlShape.isRequired,
show: React.PropTypes.bool.isRequired, show: React.PropTypes.bool.isRequired,
onModalDismissed: React.PropTypes.func.isRequired, onModalDismissed: React.PropTypes.func.isRequired,
filenames: React.PropTypes.array, fileInfos: React.PropTypes.arrayOf(React.PropTypes.object).isRequired,
modalId: React.PropTypes.string,
channelId: React.PropTypes.string,
userId: React.PropTypes.string,
startId: React.PropTypes.number startId: React.PropTypes.number
}; };
@@ -405,10 +344,10 @@ LoadingImagePreview.propTypes = {
loading: React.PropTypes.string loading: React.PropTypes.string
}; };
function ImagePreview({filename, fileUrl, fileInfo, maxHeight}) { function ImagePreview({fileInfo, fileUrl, maxHeight}) {
let previewUrl; let previewUrl;
if (fileInfo.has_preview_image) { if (fileInfo.has_preview_image) {
previewUrl = Utils.getPreviewImagePath(filename); previewUrl = FileStore.getFilePreviewUrl(fileInfo.id);
} else { } else {
previewUrl = fileUrl; previewUrl = fileUrl;
} }
@@ -429,10 +368,7 @@ function ImagePreview({filename, fileUrl, fileInfo, maxHeight}) {
} }
ImagePreview.propTypes = { ImagePreview.propTypes = {
filename: React.PropTypes.string.isRequired,
fileUrl: React.PropTypes.string.isRequired,
fileInfo: React.PropTypes.object.isRequired, fileInfo: React.PropTypes.object.isRequired,
fileUrl: React.PropTypes.string.isRequired,
maxHeight: React.PropTypes.number.isRequired maxHeight: React.PropTypes.number.isRequired
}; };
export default injectIntl(ViewImageModal);

Просмотреть файл

@@ -166,6 +166,10 @@
margin: 5px 10px 5px 0; margin: 5px 10px 5px 0;
position: relative; position: relative;
width: 240px; width: 240px;
&--placeholder {
visibility: hidden;
}
} }
.post-image__load { .post-image__load {

Просмотреть файл

@@ -16,41 +16,58 @@ class FileStore extends EventEmitter {
this.handleEventPayload = this.handleEventPayload.bind(this); this.handleEventPayload = this.handleEventPayload.bind(this);
this.dispatchToken = AppDispatcher.register(this.handleEventPayload); this.dispatchToken = AppDispatcher.register(this.handleEventPayload);
this.fileInfo = new Map(); this.setMaxListeners(600);
this.fileInfosByPost = new Map();
} }
addChangeListener(callback) { addChangeListener(callback) {
this.on(CHANGE_EVENT, callback); this.on(CHANGE_EVENT, callback);
} }
removeChangeListener(callback) { removeChangeListener(callback) {
this.removeListener(CHANGE_EVENT, callback); this.removeListener(CHANGE_EVENT, callback);
} }
emitChange(filename) {
this.emit(CHANGE_EVENT, filename); emitChange() {
this.emit(CHANGE_EVENT);
} }
hasInfo(filename) { hasInfosForPost(postId) {
return this.fileInfo.has(filename); return this.fileInfosByPost.has(postId);
} }
getInfo(filename) { getInfosForPost(postId) {
return this.fileInfo.get(filename); return this.fileInfosByPost.get(postId);
} }
setInfo(filename, info) { saveInfos(postId, infos) {
this.fileInfo.set(filename, info); this.fileInfosByPost.set(postId, infos);
}
getFileUrl(fileId) {
return `/api/v3/files/${fileId}/get`;
}
getFileThumbnailUrl(fileId) {
return `/api/v3/files/${fileId}/get_thumbnail`;
}
getFilePreviewUrl(fileId) {
return `/api/v3/files/${fileId}/get_preview`;
} }
handleEventPayload(payload) { handleEventPayload(payload) {
const action = payload.action; const action = payload.action;
switch (action.type) { switch (action.type) {
case ActionTypes.RECEIVED_FILE_INFO: case ActionTypes.RECEIVED_FILE_INFOS:
this.setInfo(action.filename, action.info); // This assumes that all received file infos are for a single post
this.emitChange(action.filename); this.saveInfos(action.postId, action.infos);
this.emitChange(action.postId);
break; break;
} }
} }
} }
export default new FileStore(); export default new FileStore();

Просмотреть файл

@@ -84,7 +84,7 @@ class NotificationStoreClass extends EventEmitter {
if (msgProps.image) { if (msgProps.image) {
body = username + Utils.localizeMessage('channel_loader.uploadedImage', ' uploaded an image'); body = username + Utils.localizeMessage('channel_loader.uploadedImage', ' uploaded an image');
} else if (msgProps.otherFile) { } else if (msgProps.otherFile) {
body = Utils.localizeMessage('channel_loader.uploadedFile', ' uploaded a file'); body = username + Utils.localizeMessage('channel_loader.uploadedFile', ' uploaded a file');
} else { } else {
body = username + Utils.localizeMessage('channel_loader.something', ' did something new'); body = username + Utils.localizeMessage('channel_loader.something', ' did something new');
} }

Просмотреть файл

@@ -224,7 +224,7 @@ class PostStoreClass extends EventEmitter {
} else if (combinedPosts.posts.hasOwnProperty(pid)) { } else if (combinedPosts.posts.hasOwnProperty(pid)) {
combinedPosts.posts[pid] = Object.assign({}, np, { combinedPosts.posts[pid] = Object.assign({}, np, {
state: Constants.POST_DELETED, 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 // make sure to copy the post so that component state changes work properly
postList.posts[post.id] = Object.assign({}, post, { postList.posts[post.id] = Object.assign({}, post, {
state: Constants.POST_DELETED, state: Constants.POST_DELETED,
filenames: [] fileIds: []
}); });
} }
} }
@@ -514,7 +514,7 @@ class PostStoreClass extends EventEmitter {
} }
getEmptyDraft() { getEmptyDraft() {
return {message: '', uploadsInProgress: [], previews: []}; return {message: '', uploadsInProgress: [], fileInfos: []};
} }
storeCurrentDraft(draft) { storeCurrentDraft(draft) {

248
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));
}
);
});
});
});

Просмотреть файл

@@ -43,43 +43,5 @@ describe('Client.General', function() {
done(); 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();
}
);
});
});
}); });

Просмотреть файл

@@ -230,5 +230,7 @@ describe('Client.Posts', function() {
); );
}); });
}); });
// getFileInfosForPost is tested in client_files.test.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() { export function getMe() {
if (isCallInProgress('getMe')) { if (isCallInProgress('getMe')) {
return null; 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) { export function getStandardAnalytics(teamId) {
const callName = 'getStandardAnaytics' + teamId; const callName = 'getStandardAnaytics' + teamId;
@@ -1432,8 +1430,8 @@ export function regenCommandToken(id) {
); );
} }
export function getPublicLink(filename, success, error) { export function getPublicLink(fileId, success, error) {
const callName = 'getPublicLink' + filename; const callName = 'getPublicLink' + fileId;
if (isCallInProgress(callName)) { if (isCallInProgress(callName)) {
return; return;
@@ -1442,7 +1440,7 @@ export function getPublicLink(filename, success, error) {
callTracker[callName] = utils.getTimestamp(); callTracker[callName] = utils.getTimestamp();
Client.getPublicLink( Client.getPublicLink(
filename, fileId,
(link) => { (link) => {
callTracker[callName] = 0; callTracker[callName] = 0;

Просмотреть файл

@@ -95,7 +95,7 @@ export const ActionTypes = keyMirror({
RECEIVED_PREFERENCE: null, RECEIVED_PREFERENCE: null,
RECEIVED_PREFERENCES: null, RECEIVED_PREFERENCES: null,
DELETED_PREFERENCES: null, DELETED_PREFERENCES: null,
RECEIVED_FILE_INFO: null, RECEIVED_FILE_INFOS: null,
RECEIVED_ANALYTICS: null, RECEIVED_ANALYTICS: null,
RECEIVED_INCOMING_WEBHOOKS: null, RECEIVED_INCOMING_WEBHOOKS: null,

Просмотреть файл

@@ -136,14 +136,9 @@ export function highlight(lang, code) {
return TextFormatting.sanitizeHtml(code); return TextFormatting.sanitizeHtml(code);
} }
export function getLanguageFromFilename(filename) { export function getLanguageFromFileExtension(extension) {
const fileSplit = filename.split('.');
let ext = fileSplit.length > 1 ? fileSplit[fileSplit.length - 1] : '';
ext = ext.toLowerCase();
for (var key in HighlightedLanguages) { for (var key in HighlightedLanguages) {
if (HighlightedLanguages[key].extensions.find((x) => x === ext)) { if (HighlightedLanguages[key].extensions.find((x) => x === extension)) {
return key; return key;
} }
} }

Просмотреть файл

@@ -413,8 +413,8 @@ export function getFileType(extin) {
return 'other'; return 'other';
} }
export function getPreviewImagePathForFileType(fileTypeIn) { export function getFileIconPath(fileInfo) {
var fileType = fileTypeIn.toLowerCase(); const fileType = getFileType(fileInfo.extension);
var icon; var icon;
if (fileType in Constants.ICON_FROM_TYPE) { if (fileType in Constants.ICON_FROM_TYPE) {
@@ -451,19 +451,6 @@ export function splitFileLocation(fileLocation) {
return {ext, name: filename, path: filePath}; 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) { export function toTitleCase(str) {
function doTitleCase(txt) { function doTitleCase(txt) {
return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase(); return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();